Compare commits

..

2 Commits

Author SHA1 Message Date
pdxlocations
8c2f0429ac probably not working changes 2025-04-05 20:55:33 -07:00
pdxlocations
425ee85ef0 rename state 2025-04-05 20:06:48 -07:00
38 changed files with 1835 additions and 4886 deletions

View File

@@ -1,47 +0,0 @@
name: contact-buildx
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+a[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+b[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
workflow_dispatch:
jobs:
build-and-push-contact:
runs-on: ubuntu-latest
steps:
-
name: clone https://github.com/pdxlocations/contact.git
uses: actions/checkout@master
with:
name: pdxlocations/contact
repository: pdxlocations/contact
path: ./contact
-
name: Set up QEMU
uses: docker/setup-qemu-action@master
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@master
-
name: Login to DockerHub
uses: docker/login-action@master
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Get current commit
run: |
echo version=$(git -C ./contact rev-parse HEAD) >> $GITHUB_ENV
-
name: Build and push pdxlocations/contact
uses: docker/build-push-action@master
with:
context: ./contact
file: ./contact/Dockerfile
platforms: linux/amd64,linux/arm64,linux/armhf
push: true
tags: pdxlocations/contact:latest,pdxlocations/contact:${{ env.version }}

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

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

View File

@@ -1,11 +0,0 @@
FROM docker.io/python:3.14
COPY . /app
WORKDIR /data
# Install contact
RUN python -m pip install /app && rm -rf /app
VOLUME /data
ENTRYPOINT [ "contact" ]

View File

@@ -1,42 +1,17 @@
## Contact - A Console UI for Meshtastic
### (Formerly Curses Client for Meshtastic)
#### Powered by Meshtastic.org
### Install with:
```bash
pip install contact
```
> [!NOTE]
> Windows users must also install:
>
> ```powershell
> pip install windows-curses
> ```
> because the built-in curses module is not available on Windows.
This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores.
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
<br><br>
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `settings.py`
<img width="696" alt="Screenshot 2025-04-08 at 6 10 06PM" src="https://github.com/user-attachments/assets/3d5e3964-f009-4772-bd6e-91b907c65a3b" />
### Docker install
Install with Docker:
```
docker build -t contact .
# Change /tmp/data to a directory you'd like to persist the database in
export DATA_DIR="/tmp/contact"
mkdir -p "$DATA_DIR"
docker run -it --rm -v $DATA_DIR:/data --workdir /data --device=/dev/ttyUSB0 contact --port /dev/ttyUSB0
```
<img width="441" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dd47f52a-d4d8-4e40-8001-9ea53d87f816" />
## Message Persistence
@@ -46,24 +21,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
@@ -93,17 +58,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

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

13
contact/globals.py Normal file
View File

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

View File

@@ -1,149 +1,10 @@
#field_name, "Human readable field name with first word capitalized", "Help text with [warning]warnings[/warning], [note]notes[/note], [underline]underlines[/underline], \033[31mANSI color codes\033[0m and \nline breaks."
Main Menu, "Main Menu", ""
User Settings, "User Settings", ""
Channels, "Channels", ""
Radio Settings, "Radio Settings", ""
Module Settings, "Module Settings", ""
App Settings, "App Settings", ""
Export Config File, "Export Config File", ""
Load Config File, "Load Config File", ""
Config URL, "Config URL", ""
Reboot, "Reboot", ""
Reset Node DB, "Reset Node DB", ""
Shutdown, "Shutdown", ""
Factory Reset, "Factory Reset", ""
Exit, "Exit", ""
Yes, "Yes", ""
No, "No", ""
Cancel, "Cancel", ""
[ui]
save_changes, "Save Changes", ""
dialog.invalid_input, "Invalid Input", ""
prompt.enter_new_value, "Enter new value: ", ""
error.value_empty, "Value cannot be empty.", ""
error.value_exact_length, "Value must be exactly {length} characters long.", ""
error.value_min_length, "Value must be at least {length} characters long.", ""
error.value_max_length, "Value must be no more than {length} characters long.", ""
error.digits_only, "Only numeric digits (0-9) allowed.", ""
error.number_range, "Enter a number between {min_value} and {max_value}.", ""
error.float_invalid, "Must be a valid floating point number.", ""
prompt.edit_admin_keys, "Edit up to 3 Admin Keys:", ""
label.admin_key, "Admin Key", ""
error.admin_key_invalid, "Error: Each key must be valid Base64 and 32 bytes long!", ""
prompt.edit_values, "Edit up to 3 Values:", ""
label.value, "Value", ""
prompt.enter_ip, "Enter an IP address (xxx.xxx.xxx.xxx):", ""
label.current, "Current", ""
label.new_value, "New value", ""
label.editing, "Editing {label}", ""
label.current_value, "Current Value:", ""
error.ip_invalid, "Invalid IP address. Try again.", ""
prompt.select_foreground_color, "Select Foreground Color for {label}", ""
prompt.select_background_color, "Select Background Color for {label}", ""
prompt.select_value, "Select {label}", ""
confirm.save_before_exit, "You have unsaved changes. Save before exiting?", ""
prompt.config_filename, "Enter a filename for the config file", ""
confirm.overwrite_file, "{filename} already exists. Overwrite?", ""
dialog.config_saved_title, "Config File Saved:", ""
dialog.no_config_files, " No config files found. Export a config first.", ""
prompt.choose_config_file, "Choose a config file", ""
confirm.load_config_file, "Are you sure you want to load {filename}?", ""
prompt.config_url_current, "Config URL is currently: {value}", ""
confirm.load_config_url, "Are you sure you want to load this config?", ""
confirm.reboot, "Are you sure you want to Reboot?", ""
confirm.reset_node_db, "Are you sure you want to Reset Node DB?", ""
confirm.shutdown, "Are you sure you want to Shutdown?", ""
confirm.factory_reset, "Are you sure you want to Factory Reset?", ""
confirm.save_before_exit_section, "You have unsaved changes in {section}. Save before exiting?", ""
prompt.select_region, "Select your region:", ""
dialog.slow_down_title, "Slow down", ""
dialog.slow_down_body, "Please wait 2 seconds between messages.", ""
dialog.node_details_title, "📡 Node Details: {name}", ""
dialog.traceroute_not_sent_title, "Traceroute Not Sent", ""
dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending another traceroute.", ""
dialog.traceroute_sent_title, "Traceroute Sent To: {name}", ""
dialog.traceroute_sent_body, "Results will appear in messages window.", ""
dialog.help_title, "Help - Shortcut Keys", ""
help.scroll, "Up/Down = Scroll", ""
help.switch_window, "Left/Right = Switch window", ""
help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", ""
help.enter, "ENTER = Send / Select", ""
help.settings, "` or F12 = Settings", ""
help.quit, "ESC = Quit", ""
help.packet_log, "Ctrl+P = Toggle Packet Log", ""
help.traceroute, "Ctrl+T or F4 = Traceroute", ""
help.node_info, "F5 = Full node info", ""
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
help.favorite, "Ctrl+F = Favorite", ""
help.ignore, "Ctrl+G = Ignore", ""
help.search, "Ctrl+/ = Search", ""
help.help, "Ctrl+K = Help", ""
help.no_help, "No help available.", ""
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
confirm.set_favorite, "Set {name} as Favorite?", ""
confirm.remove_favorite, "Remove {name} from Favorites?", ""
confirm.set_ignored, "Set {name} as Ignored?", ""
confirm.remove_ignored, "Remove {name} from Ignored?", ""
confirm.region_unset, "Your region is UNSET. Set it now?", ""
dialog.resize_title, "Resize Terminal", ""
dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
[User Settings]
user, "User"
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
[app_settings]
title, "App Settings", ""
channel_list_16ths, "Channel list width", "Width of channel list in sixteenths of the screen."
node_list_16ths, "Node list width", "Width of node list in sixteenths of the screen."
single_pane_mode, "Single pane mode", "Show a single-pane layout."
db_file_path, "Database file path", ""
log_file_path, "Log file path", ""
node_configs_file_path, "Node configs path", ""
language, "Language", "UI language for labels and help text."
message_prefix, "Message prefix", ""
sent_message_prefix, "Sent message prefix", ""
notification_symbol, "Notification symbol", ""
notification_sound, "Notification sound", ""
ack_implicit_str, "ACK (implicit)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "ACK (unknown)", ""
node_sort, "Node sort", ""
theme, "Theme", ""
COLOR_CONFIG_DARK, "Theme colors (dark)", ""
COLOR_CONFIG_LIGHT, "Theme colors (light)", ""
COLOR_CONFIG_GREEN, "Theme colors (green)", ""
[app_settings.color_config]
default, "Default", ""
background, "Background", ""
splash_logo, "Splash logo", ""
splash_text, "Splash text", ""
input, "Input", ""
node_list, "Node list", ""
channel_list, "Channel list", ""
channel_selected, "Channel selected", ""
rx_messages, "Received messages", ""
tx_messages, "Sent messages", ""
timestamps, "Timestamps", ""
commands, "Commands", ""
window_frame, "Window frame", ""
window_frame_selected, "Window frame selected", ""
log_header, "Log header", ""
log, "Log", ""
settings_default, "Settings default", ""
settings_sensitive, "Settings sensitive", ""
settings_save, "Settings save", ""
settings_breadcrumbs, "Settings breadcrumbs", ""
settings_warning, "Settings warning", ""
settings_note, "Settings note", ""
node_favorite, "Node favorite", ""
node_ignored, "Node ignored", ""
[Channels.channel]
title, "Channels"
channel_num, "Channel number", "The index number of this channel."
@@ -153,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."
@@ -169,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"
@@ -217,14 +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", ""
ip, "IP", ""
gateway, "Gateway", ""
subnet, "Subnet", ""
dns, "DNS", ""
[config.display]
title, "Display"
@@ -253,35 +105,6 @@ theme, "Theme", ""
alert_enabled, "Alert enabled", ""
banner_enabled, "Banner enabled", ""
ring_tone_id, "Ring tone ID", ""
language, "Language", ""
node_filter, "Node Filter", ""
node_highlight, "Node Highlight", ""
calibration_data, "Calibration Data", ""
map_data, "Map Data", ""
[config.device_ui.node_filter]
title, "Node Filter"
unknown_switch, "Unknown Switch", ""
offline_switch, "Offline Switch", ""
public_key_switch, "Public Key Switch", ""
hops_away, "Hops Away", ""
position_switch, "Position Switch", ""
node_name, "Node Name", ""
channel, "Channel", ""
[config.device_ui.node_highlight]
title, "Node Highlight"
chat_switch, "Chat Switch", ""
position_switch, "Position Switch", ""
telemetry_switch, "Telemetry Switch", ""
iaq_switch, "IAQ Switch", ""
node_name, "Node Name", ""
[config.device_ui.map_data]
title, "Map Data"
home, "Home", ""
style, "Style", ""
follow_gps, "Follow GPS", ""
[config.lora]
title, "LoRa"
@@ -299,7 +122,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."
@@ -331,10 +154,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"
@@ -351,9 +172,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."
@@ -395,7 +216,6 @@ power_screen_enabled, "Power screen enabled", "Show the power telemetry data on
health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds."
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
[module.canned_message]
title, "Canned Message"
@@ -424,8 +244,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"
@@ -456,5 +275,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,460 +0,0 @@
#field_name, "Human readable field name with first word capitalized", "Help text with [warning]warnings[/warning], [note]notes[/note], [underline]underlines[/underline], \033[31mANSI color codes\033[0m and \nline breaks."
Main Menu, "Главное меню", ""
User Settings, "Настройки пользователя", ""
Channels, "Каналы", ""
Radio Settings, "Настройки радио", ""
Module Settings, "Настройки модулей", ""
App Settings, "Настройки приложения", ""
Export Config File, "Экспорт конфигурации", ""
Load Config File, "Загрузить конфигурацию", ""
Config URL, "URL конфигурации", ""
Reboot, "Перезагрузить", ""
Reset Node DB, "Сбросить БД узлов", ""
Shutdown, "Выключить", ""
Factory Reset, "Сброс до заводских", ""
Exit, "Выход", ""
Yes, "Да", ""
No, "Нет", ""
Cancel, "Отмена", ""
[ui]
save_changes, "Сохранить изменения", ""
dialog.invalid_input, "Некорректный ввод", ""
prompt.enter_new_value, "Введите новое значение: ", ""
error.value_empty, "Значение не может быть пустым.", ""
error.value_exact_length, "Значение должно быть длиной ровно {length} символов.", ""
error.value_min_length, "Значение должно быть не короче {length} символов.", ""
error.value_max_length, "Значение должно быть не длиннее {length} символов.", ""
error.digits_only, "Разрешены только цифры (0-9).", ""
error.number_range, "Введите число между {min_value} и {max_value}.", ""
error.float_invalid, "Введите корректное число с плавающей точкой.", ""
prompt.edit_admin_keys, "Редактировать до 3 ключей администратора:", ""
label.admin_key, "Ключ администратора", ""
error.admin_key_invalid, "Ошибка: каждый ключ должен быть Base64 и длиной 32 байта.", ""
prompt.edit_values, "Редактировать до 3 значений:", ""
label.value, "Значение", ""
prompt.enter_ip, "Введите IP-адрес (xxx.xxx.xxx.xxx):", ""
label.current, "Текущее", ""
label.new_value, "Новое значение", ""
label.editing, "Редактирование {label}", ""
label.current_value, "Текущее значение:", ""
error.ip_invalid, "Неверный IP-адрес. Попробуйте еще раз.", ""
prompt.select_foreground_color, "Выберите цвет текста для {label}", ""
prompt.select_background_color, "Выберите цвет фона для {label}", ""
prompt.select_value, "Выберите {label}", ""
confirm.save_before_exit, "Есть несохраненные изменения. Сохранить перед выходом?", ""
prompt.config_filename, "Введите имя файла конфигурации", ""
confirm.overwrite_file, "Файл {filename} уже существует. Перезаписать?", ""
dialog.config_saved_title, "Файл конфигурации сохранен:", ""
dialog.no_config_files, " Нет файлов конфигурации. Сначала экспортируйте конфигурацию.", ""
prompt.choose_config_file, "Выберите файл конфигурации", ""
confirm.load_config_file, "Загрузить файл {filename}?", ""
prompt.config_url_current, "Текущий URL конфигурации: {value}", ""
confirm.load_config_url, "Загрузить эту конфигурацию?", ""
confirm.reboot, "Перезагрузить устройство?", ""
confirm.reset_node_db, "Сбросить БД узлов?", ""
confirm.shutdown, "Выключить устройство?", ""
confirm.factory_reset, "Сбросить до заводских настроек?", ""
confirm.save_before_exit_section, "Есть несохраненные изменения в {section}. Сохранить перед выходом?", ""
prompt.select_region, "Выберите ваш регион:", ""
dialog.slow_down_title, "Подождите", ""
dialog.slow_down_body, "Подождите 2 секунды между сообщениями.", ""
dialog.node_details_title, "📡 Информация об узле: {name}", ""
dialog.traceroute_not_sent_title, "Traceroute не отправлен", ""
dialog.traceroute_not_sent_body, "Подождите {seconds} секунд перед повторной отправкой traceroute.", ""
dialog.traceroute_sent_title, "Traceroute отправлен: {name}", ""
dialog.traceroute_sent_body, "Результаты появятся в окне сообщений.", ""
dialog.help_title, "Справка - горячие клавиши", ""
help.scroll, "Вверх/Вниз = Прокрутка", ""
help.switch_window, "Влево/Вправо = Переключить окно", ""
help.jump_windows, "F1/F2/F3 = Каналы/Сообщения/Узлы", ""
help.enter, "ENTER = Отправить / Выбрать", ""
help.settings, "` или F12 = Настройки", ""
help.quit, "ESC = Выход", ""
help.packet_log, "Ctrl+P = Журнал пакетов", ""
help.traceroute, "Ctrl+T или F4 = Traceroute", ""
help.node_info, "F5 = Полная информация об узле", ""
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
help.favorite, "Ctrl+F = Избранное", ""
help.ignore, "Ctrl+G = Игнорировать", ""
help.search, "Ctrl+/ = Поиск", ""
help.help, "Ctrl+K = Справка", ""
help.no_help, "Нет справки.", ""
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
confirm.set_favorite, "Добавить {name} в избранное?", ""
confirm.remove_favorite, "Удалить {name} из избранного?", ""
confirm.set_ignored, "Игнорировать {name}?", ""
confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
dialog.resize_title, "Увеличьте окно", ""
dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", ""
[User Settings]
user, "Пользователь"
longName, "Полное имя ноды", "Если вы являетесь лицензированным оператором HAM и включили режим HAM, этот режим должен быть установлен в качестве позывного вашего оператора HAM."
shortName, "Краткое имя ноды", "Должно быть не более 4 байт. Обычно это 4 символа, если используются латинские символы и без эмодзи."
isLicensed, "Включите лицензионный любительский режим (HAM)", "ВАЖНО: перед включением ознакомьтесь со справочной документацией Meshtastic."
[app_settings]
title, "Настройки приложения", ""
channel_list_16ths, "Ширина списка каналов", "Ширина списка каналов в шестнадцатых долях экрана."
node_list_16ths, "Ширина списка нод", "Ширина списка нод в шестнадцатых долях экрана."
single_pane_mode, "Однопанельный режим", "Показывать интерфейс в одной панели."
db_file_path, "Путь к базе данных", ""
log_file_path, "Путь к файлу журнала", ""
node_configs_file_path, "Путь к конфигурациям нод", ""
language, "Язык", "Язык интерфейса для подписей и справки."
message_prefix, "Префикс сообщений", ""
sent_message_prefix, "Префикс отправленных", ""
notification_symbol, "Символ уведомления", ""
notification_sound, "Звук уведомления", ""
ack_implicit_str, "ACK (неявный)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "ACK (неизвестный)", ""
node_sort, "Сортировка нод", ""
theme, "Тема", ""
COLOR_CONFIG_DARK, "Цвета темы (темная)", ""
COLOR_CONFIG_LIGHT, "Цвета темы (светлая)", ""
COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
[app_settings.color_config]
default, "По умолчанию", ""
background, "Фон", ""
splash_logo, "Логотип заставки", ""
splash_text, "Текст заставки", ""
input, "Ввод", ""
node_list, "Список нод", ""
channel_list, "Список каналов", ""
channel_selected, "Выбранный канал", ""
rx_messages, "Входящие сообщения", ""
tx_messages, "Отправленные сообщения", ""
timestamps, "Временные метки", ""
commands, "Команды", ""
window_frame, "Рамка окна", ""
window_frame_selected, "Выбранная рамка окна", ""
log_header, "Заголовок лога", ""
log, "Лог", ""
settings_default, "Настройки по умолчанию", ""
settings_sensitive, "Чувствительные настройки", ""
settings_save, "Сохранение настроек", ""
settings_breadcrumbs, "Хлебные крошки", ""
settings_warning, "Предупреждения настроек", ""
settings_note, "Примечания настроек", ""
node_favorite, "Избранная нода", ""
node_ignored, "Игнорируемая нода", ""
[Channels.channel]
title, "Каналы"
channel_num, "Номер канала", "Номер индекса этого канала."
psk, "PSK", "Ключи шифрования каналов."
name, "Name", "Имена каналов."
id, "", ""
uplink_enabled, "Восходящая линия вклюена", "Пусть данные этого канала отправляются на сервер MQTT, настроенный на этом узле."
downlink_enabled, "Входящая линия включена", "Пусть данные с сервера MQTT, настроенного на этом узле, отправляются на этот канал."
module_settings, "Настройки модуля", "Точность позиционирования и отключение звука клиента."
module_settings.position_precision, "Точность позиционирования", "Уровень точности данных о местоположении, передаваемых по этому каналу."
module_settings.is_client_muted, "Приглушен ли клиент", "Определяет, должен ли телефон/клиенты приглушать звук текущего канала. Полезно для общих каналов с шумом, которые вы не хотите отключать."
[config.device]
title, "Устройство"
role, "Роль", "Для подавляющего большинства пользователей правильным выбором является клиент. Дополнительную информацию смотрите в документации Meshtastic."
serial_enabled, "Включить последовательную консоль", "Последовательная консоль через Stream API."
button_gpio, "Кнопка GPIO", "Пин-код GPIO для пользовательской кнопки."
buzzer_gpio, "Зуммер GPIO", "Пин-код GPIO для пользовательского зуммера."
rebroadcast_mode, "Режим ретрансляции", "Этот параметр определяет поведение устройства при ретрансляции сообщений."
node_info_broadcast_secs, "Интервал широковещательной передачи Nodeinfo", "Это количество секунд между передачами сообщения NodeInfo. Также будет отправлено сообщение nodeinfo в ответ на появление новых узлов в сети."
double_tap_as_button_press, "Двойной тап как нажатие кнопки", "Эта опция позволяет использовать двойной тап, когда к устройству подключен поддерживаемый акселерометр, как нажатие кнопки."
is_managed, "Включить управляемый режим", "Включение управляемого режима блокирует изменение конфигурации приложений для смартфонов и веб-интерфейса. [note]Этот параметр не требуется для удаленного администрирования узла.[/note] Перед включением убедитесь, что узлом можно управлять с помощью удаленного администратора, чтобы [warning]предотвратить его блокировку.[/warning]"
disable_triple_click, "Отключить тройное нажатие кнопки", ""
tzdef, "Часовой пояс", "Использует формат базы данных ЧП для отображения правильного местного времени на дисплее устройства и в его журналах."
led_heartbeat_disabled, "Отключить LED пульс", "На некоторых моделях оборудования это отключает мигающий индикатор пульса."
buzzer_mode, "Режим зуммера", "Управляет поведением зуммера для получения звуковой обратной связи."
[config.position]
title, "Позиционирование"
position_broadcast_secs, "Интервал широковещательной передачи местоположения", "Если умная трансляция отключена - мы должны сообщать о своем местоположении так часто."
position_broadcast_smart_enabled, "Включена умная трансляция местоположения", "Умная трансляция будет передавать информацию о вашем местоположении с увеличенной частотой только в том случае, если оно изменилось настолько, что его обновление будет полезным."
fixed_position, "Фиксированное местоположение", "Если этот параметр установлен - используется фиксированное положение. Устройство будет генерировать обновления GPS, но использовать последние значения широты/долготы/высоты, сохраненные для ноды. Положение может быть задано с помощью встроенного GPS или GPS смартфона."
latitude, "Широта", ""
longitude, "Долгота", ""
altitude, "Высота", ""
gps_enabled, "GPS включен", ""
gps_update_interval, "Интервал обновления GPS", "Как часто мы должны пытаться определить местоположение по GPS (в секундах), или нулевое значение по умолчанию - раз в 2 минуты, или очень большое значение (maxint) для обновления только один раз при загрузке."
gps_attempt_time, "Время попытки GPS", ""
position_flags, "Флаги позиционирования", "Смотрите документацию Meshtastic для подробностей."
rx_gpio, "GPS RX GPIO pin", "Если на вашем устройстве нет встроенного GPS-чипа, то можете определить контакты GPIO для RX-контакта GPS-модуля."
tx_gpio, "GPS TX GPIO pin", "Если на вашем устройстве нет встроенного GPS-чипа, то можете определить контакты GPIO для TX-контакта GPS-модуля."
broadcast_smart_minimum_distance, "Минимальное расстояние умного позиционирования по GPS", "Минимальное пройденное расстояние в метрах (с момента последней отправки), прежде чем мы сможем отправить местоположение в сеть, если включена умная трансляция."
broadcast_smart_minimum_interval_secs, "Минимальный интервал умного позиционирования по GPS", "Минимальное количество секунд (с момента последней отправки), прежде чем мы сможем отправить позицию в сеть, если включена умная трансляция."
gps_en_gpio, "GPIO включения GPS", ""
gps_mode, "Режим GPS", "Определяет, включена ли функция GPS, отключена или отсутствует на узле."
[config.power]
title, "Мощность"
is_power_saving, "Включить режим энергосбережения", "Автоматическое выключение устройства по истечении этого времени в случае отключения питания."
on_battery_shutdown_after_secs, "Интервал отключения батареи", ""
adc_multiplier_override, "Переопределение множителя АЦП", "Коэффициент делителя напряжения для вывода батареи. Переопределяет значение ADC_MULTIPLIER, определенное в файле вариантов встроенного устройства, для расчета напряжения батареи. Дополнительную информацию смотрите в документации Meshtastic."
wait_bluetooth_secs, "Bluetooth", "Как долго нужно ждать, прежде чем выключать BLE, если устройство Bluetooth не подключено."
sds_secs, "Интервал сверхглубокого сна", "Находясь в режиме легкого сна, если значение mesh_sds_timeout_secs превышено, мы перейдем в режим сверхглубокого сна на это значение или нажмем кнопку. 0 по умолчанию - один год."
ls_secs, "Интервал легкого сна", "Только ESP32. В режиме легкого сна процессор приостанавливает работу, передатчик LoRa включен, BLE выключен и GPS включен."
min_wake_secs, "Минимальный интервал пробуждения", "Находясь в состоянии легкого сна, когда мы получаем пакеты по LoRa, мы просыпаемся, обрабатываем их и остаемся бодрствовать в режиме без Bluetooth в течение этого интервала в секундах."
device_battery_ina_address, "Батарея устройства по адресу INA2xx", "Если устройство INA-2XX автоматически обнаруживается на одной из шин I2C по указанному адресу, оно будет использоваться в качестве надежного источника для считывания уровня заряда батареи устройства. Для устройств с PMU (например, T-beams) настройка игнорируется"
powermon_enables, "Включение монитора мощности", "Если значение не равно нулю - нам нужны выходные данные журнала powermon. С включенными конкретными источниками (битовое поле)."
[config.network]
title, "Сеть"
wifi_enabled, "Wi-Fi включен", "Включает или отключает Wi-Fi."
wifi_ssid, "Wi-Fi SSID", "SSID вашей Wi-Fi сети."
wifi_psk, "Wi-Fi PSK", "Пароль вашей Wi-Fi сети."
ntp_server, "NTP-сервер", "Сервер времени, используемый при наличии IP-сети."
eth_enabled, "Ethernet включен", "Включает или отключает Ethernet на некоторых моделях оборудования."
address_mode, "Сетевой режим IPv4", "По умолчанию установлен DHCP. Измените значение на STATIC для использования статического IP-адреса. Применяется как к Ethernet, так и к Wi-Fi."
ipv4_config, "Настройка IPv4", "Расширенные настройки сети"
ip, "Статический адрес IPv4", ""
gateway, "IPv4 шлюз", ""
subnet, "IPv4 подсеть", ""
dns, "IPv4 DNS-сервер", ""
rsyslog_server, "RSyslog сервер", ""
enabled_protocols, "Включенные протоколы", ""
ipv6_enabled, "Включить IPv6", "Включает или отключает подключение к сети IPv6."
[config.network.ipv4_config]
title, "Конфигурация IPv4", ""
ip, "IP", ""
gateway, "Шлюз", ""
subnet, "Подсеть", ""
dns, "DNS", ""
[config.display]
title, "Дисплей"
screen_on_secs, "Длительность включения экрана", "Как долго экран остается включенным в секундах после нажатия пользовательской кнопки или получения сообщений."
gps_format, "Формат GPS", "Формат, используемый для отображения GPS-координат на экране устройства."
auto_screen_carousel_secs, "Интервал автокарусели", "Автоматическое переключение на следующую страницу на экране, как в карусели, в зависимости от заданного интервала в секундах."
compass_north_top, "Всегда указывать на север", "Если этот параметр установлен, направление по компасу на экране всегда будет указывать на север. По умолчанию эта функция отключена, и в верхней части дисплея отображается направление вашего движения, индикатор Севера будет перемещаться по кругу."
flip_screen, "Перевернуть экран", "Следует ли перевернуть экран по вертикали."
units, "Предпочитаемые единицы измерения", "Выбор между метрической (по умолчанию) и британской системами измерений."
oled, "Определение OLED", "Тип OLED-контроллера определяется автоматически по умолчанию, но может быть определен с помощью этого параметра, если автоматическое определение не удается. Для SH1107 мы предполагаем квадратный дисплей с разрешением 128x128 пикселей, как у GME128128-1."
displaymode, "Режим дисплея", "DEFAULT, TWOCOLOR, INVERTED или COLOR. TWOCOLOR: предназначен для OLED-дисплеев с другой цветовой гаммой первой строки. INVERTED: инвертирует двухцветную область, в результате чего заголовок на монохромном дисплее будет отображаться на белом фоне."
heading_bold, "Жирные заголовки", "Заголовок может быть трудно читаем, если используется INVERTED или TWOCOLOR режим отображения. При этой настройке заголовок будет выделен жирным шрифтом, что облегчит его чтение."
wake_on_tap_or_motion, "Пробуждение при нажатии или движении", "Эта опция позволяет активировать экран устройства при обнаружении движения, например, прикосновения к устройству, с помощью подключенного акселерометра или емкостной сенсорной кнопки."
compass_orientation, "Ориентация компаса", "Следует ли поворачивать компас."
use_12h_clock, "Использовать 12-часовой формат часов"
[config.device_ui]
title, "UI устройства"
version, "Версия", ""
screen_brightness, "Яркость экрана", ""
screen_timeout, "Тайм-аут подсветки", ""
screen_lock, "Блокировка экрана", ""
settings_lock, "Настройка блокировки", ""
pin_code, "PIN-код", ""
theme, "Тема", ""
alert_enabled, "Оповещение включено", ""
banner_enabled, "Баннер включен", ""
ring_tone_id, "ID рингтона", ""
language, "Язык", ""
node_filter, "Фильтр нод", ""
node_highlight, "Подсветка ноды", ""
calibration_data, "Калибровочные данные", ""
map_data, "Данные карты", ""
[config.device_ui.node_filter]
title, "Фильтр ноды"
unknown_switch, "Неизвестный переключатель", ""
offline_switch, "Offline Switch", ""
public_key_switch, "Public Key Switch", ""
hops_away, "Hops Away", ""
position_switch, "Переключатель позиционирования", ""
node_name, "Имя ноды", ""
channel, "Канал", ""
[config.device_ui.node_highlight]
title, "Подстветка ноды"
chat_switch, "Переключатель чата", ""
position_switch, "Переключатель позицонирования", ""
telemetry_switch, "Переключатель телеметрии", ""
iaq_switch, "Переключатель IAQ", ""
node_name, "Имя ноды", ""
[config.device_ui.map_data]
title, "Данные карты"
home, "Домой", ""
style, "Стиль", ""
follow_gps, "Следовать GPS", ""
[config.lora]
title, "LoRa"
use_preset, "Использовать предустановку модема", "Предустановки - это заранее определенные настройки модема (пропускная способность, коэффициент распространения и скорость кодирования), которые влияют как на скорость передачи сообщений, так и на дальность действия. Подавляющее большинство пользователей используют предустановки."
modem_preset, "Предустановка", "Предустановка по умолчанию обеспечит оптимальное сочетание скорости и диапазона для большинства пользователей."
bandwidth, "Пропускная способность", "Ширина частотного 'диапазона', используемого вокруг расчетной центральной частоты. Используется только в том случае, если предустановка модема отключена."
spread_factor, "Коэффициент распространения", "Указывает количество chirps на символ. Используется только в том случае, если предустановка модема отключена."
coding_rate, "Скорость кодирования", "Доля каждой передачи LoRa, содержащая фактические данные, - остальное используется для коррекции ошибок."
frequency_offset, "Смещение частоты", "Этот параметр предназначен для опытных пользователей с современным испытательным оборудованием."
region, "Регион", "Задает регион для вашей ноды. Если этот параметр не задан, нода будет отображать сообщение и не будет передавать никаких пакетов."
hop_limit, "Лимит хопов", "Максимальное количество промежуточных узлов между нашей нодой и нодой, на которую отправляется пакет. Не влияет на принимаемые сообщения.\n[warning]Превышение лимита хопов увеличивает перегрузку![/warning]\n Должно быть в диапазоне от 0 до 7."
tx_enabled, "Включить TX", "Включает/выключает радиочип. Полезно для 'горячей' замены антенн."
tx_power, "Мощность TX в dBm", "[warning]Установка радиоприемника мощностью 33 дБ выше 8 дБ приведет к его необратимому повреждению. ERP выше 27 дБ нарушает законодательство ЕС. ERP выше 36 дБ нарушает законодательство США (нелицензионное).[/warning] Если значение равно 0, будет использоваться максимальная постоянная мощность, действующая в регионе. Должно быть 0-30 (0=автоматически)."
channel_num, "Частотный слот", "Определяет точную частоту, которую радиостанция передает и принимает. Если параметр не задан или установлен на 0, он автоматически определяется по названию основного канала."
override_duty_cycle, "Изменить рабочий цикл", "Отменитm установленное законом ограничение по времени передачи, чтобы разрешить неограниченное время передачи. [warning]Может иметь юридические последствия.[/warning]"
sx126x_rx_boosted_gain, "Включить усиление SX126X RX", "Эта опция, характерная для чипов серии SX126x, позволяет чипу потреблять небольшое количество дополнительной энергии для повышения чувствительности приемника."
override_frequency, "Переопределение частоты в MHz", "Переопределяет частотный диапазон. Может иметь юридические последствия."
pa_fan_disabled, "Отключение PA Fan", "Если значение равно true, отключает встроенный PA FAN, используя pin-код, указанный в RF95_FAN_EN"
ignore_mqtt, "Игнорировать MQTT", "Игнорировать все сообщения, получаемые через LoRa и которые пришли через MQTT где-то на пути к устройству."
config_ok_to_mqtt, "OK для MQTT", "Указывает, что пользователь одобряет передачу своих пакетов брокеру MQTT."
[config.bluetooth]
title, "Bluetooth"
enabled, "Включен", "Включает Bluetooth. Еще бы!"
mode, "Режим сопряжения", "RANDOM_PIN генерирует случайный PIN-код во время выполнения. В FIXED_PIN используется фиксированный PIN-код, который затем должен быть указан дополнительно. Наконец, NO_PIN отключает аутентификацию с помощью PIN-кода."
fixed_pin, "Фиксированный PIN", "Если для вашего режима сопряжения задано значение FIXED_PIN, значение по умолчанию 123456. Для всех других режимов сопряжения это число игнорируется. Пользовательское целое число (6 цифр) можно задать с помощью параметров настройки Bluetooth."
[config.security]
title, "Безопасность"
public_key, "Открытый ключ", "Открытый ключ устройства, используемый совместно с другими узлами сети, чтобы они могли вычислить общий секретный ключ для безопасной связи. Генерируется автоматически в соответствии с закрытым ключом.\n[warning]Не меняйте его, если не знаете что делаете.[/warning]"
private_key, "Закрытый ключ", "Закрытый ключ устройства, используемый для создания общего ключа с удаленным устройством для безопасной связи.\n[warning]Этот ключ должен храниться в тайне.[/warning]\n[note]Установка неверного ключа приведет к непредвиденным последствиям.[/note]
is_managed, "Включить управляемый режим", "Включение управляемого режима блокирует изменение конфигурации приложений для смартфонов и веб-интерфейса. [note]Этот параметр не требуется для удаленного администрирования узла.[/note] Перед включением убедитесь, что узлом можно управлять с помощью удаленного администрирования, чтобы [warning]предотвратить его блокировку.[/warning]"
serial_enabled, "Включить последовательную консоль", ""
debug_log_api_enabled, "Включить лог дебага", "Установите для этого параметра значение true, чтобы продолжить вывод журналов отладки в реальном времени по последовательному каналу или Bluetooth, когда API активен."
admin_channel_enabled, "Включить устаревший канал админа", "Если узел, который вы хотите администрировать или которым вы будете управлять, работает под управлением 2.4.x или более ранней версии, вам следует установить для этого значения включено. Требуется, чтобы на обоих узлах присутствовал дополнительный канал с именем 'admin'."
admin_key, "Админский ключ", "Открытый ключ(и), разрешающий администрирование этого узла. Только сообщения, подписанные этими ключами, будут приниматься для администрирования. Не более 3."
[module.mqtt]
title, "MQTT"
enabled, "Включен", "Включает модуль MQTT."
address, "Адрес сервера", "The server to use for MQTT. If not set, the default public server will be used."
username, "Имя пользователя", "MQTT Server username to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default public server, this will only be honored if set, otherwise the device will use the default username."
password, "Пароль", "MQTT password to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default server, this will only be honored if set, otherwise the device will use the default password."
encryption_enabled, "Encryption enabled", "Whether to send encrypted or unencrypted packets to the MQTT server. Unencrypted packets may be useful for external systems that want to consume meshtastic packets. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set."
json_enabled, "JSON включен", "Enable the sending / consumption of JSON packets on MQTT. These packets are not encrypted, but offer an easy way to integrate with systems that can read JSON. JSON is not supported on the nRF52 platform."
tls_enabled, "TLS включен", "If true, we attempt to establish a secure connection using TLS."
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"
enabled, "Включен", "Включает модуль."
echo, "Эхо", "Если установлено - все отправляемые вами пакеты будут отправляться обратно на ваше устройство."
rxd, "Получение пина GPIO", "Установите pin-код GPIO на заданный вами RXD-код."
txd, "Передача пина GPIO", "Установите pin-код GPIO на заданный вами TXD-код."
baud, "Скорость передачи в бодах", "Последовательная скорость передачи данных в бодах."
timeout, "Тайм-аут", "Количество времени, которое необходимо подождать, прежде чем мы сочтем ваш пакет отправленным."
mode, "Режим", "Смотрите документацию Meshtastic для получения дополнительной информации."
override_console_serial_port, "Переопределение последовательного порта консоли", "Если установлено true, это позволит последовательному модулю управлять (устанавливать скорость передачи данных в бодах) и использовать основную последовательную шину USB для вывода данных. Это полезно только для режимов NMEA и CalTopo и может вести себя странно или вообще не работать в других режимах. Установка контактов TX/RX в конфигурации последовательного модуля приведет к игнорированию этой настройки."
[module.external_notification]
title, "Внешния уведомления"
enabled, "Включен", "Включает модуль."
output_ms, "Длина", "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."
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."
alert_message_buzzer, "Alert buzzer on message", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character."
alert_bell, "Alert when receiving a bell (general)", "Specifies if an alert should be triggered when receiving an incoming bell with an alert bell character."
alert_bell_vibra, "Alert vibration on bell", "Specifies if a vibration alert should be triggered when receiving an incoming message with an alert bell character."
alert_bell_buzzer, "Alert buzzer on bell", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character."
use_pwm, "Use PWM for buzzer", ""
nag_timeout, "Repeat (nag timeout)", "Specifies if the alert should be repeated. If set to a value greater than zero, the alert will be repeated until the user button is pressed or 'value' number of seconds have past."
use_i2s_as_buzzer, "Use i2s as buzzer", ""
[module.store_forward]
title, "Store & Forward"
enabled, "Включен", "Включает модуль."
heartbeat, "Heartbeat", "The Store & Forward Server sends a periodic message onto the network. This allows connected devices to know that a server is in range and listening to received messages. A client like Android, iOS, or Web can (if supported) indicate to the user whether a Store & Forward Server is available."
records, "Records", "Set this to the maximum number of records the server will save. Best to leave this at the default (0) where the module will use 2/3 of your device's available PSRAM. This is about 11,000 records."
history_return_max, "History return max", "Sets the maximum number of messages to return to a client device when it requests the history."
history_return_window, "History return window", "Limits the time period (in minutes) a client device can request."
is_server, "Is server", "Set to true to configure your node with PSRAM as a Store & Forward Server for storing and forwarding messages. This is an alternative to setting the node as a ROUTER and only available since 2.4."
[module.range_test]
title, "Range Test"
enabled, "Включен", "Включает модуль."
sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages."
save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]"
[module.telemetry]
title, "Телеметрия"
device_update_interval, "Device metrics update interval", "How often we should send Device Metrics over the mesh in seconds."
environment_update_interval, "Environment metrics update interval", "How often we should send environment (sensor) Metrics over the mesh in seconds."
environment_measurement_enabled, "Environment telemetry enabled", "Enable the Environment Telemetry (Sensors)."
environment_screen_enabled, "Environment screen enabled", "Show the environment telemetry data on the device display."
environment_display_fahrenheit, "Display fahrenheit", "The sensor is always read in Celsius, but the user can opt to display in Fahrenheit (on the device display only) using this setting."
air_quality_enabled, "Air quality enabled", "This option is used to enable/disable the sending of air quality metrics from an attached supported sensor over the mesh network."
air_quality_interval, "Air quality interval", "This option is used to configure the interval in seconds that should be used to send air quality metrics from an attached supported sensor over the mesh network in seconds."
power_measurement_enabled, "Power metrics enabled", "This option is used to enable/disable the sending of power telemetry as gathered by an attached supported voltage/current sensor. Note that this does not need to be enabled to monitor the voltage of the battery."
power_update_interval, "Power metrics interval", "This option is used to configure the interval in seconds that should be used to send power metrics from an attached supported sensor over the mesh network in seconds."
power_screen_enabled, "Power screen enabled", "Show the power telemetry data on the device display."
health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds."
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
[module.canned_message]
title, "Canned Message"
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
inputbroker_pin_press, "Input broker pin press", "GPIO Pin Value (1-39) For encoder Press port."
inputbroker_event_cw, "Input broker event clockwise", "Generate the rotary clockwise event."
inputbroker_event_ccw, "Input broker event counter clockwise", "Generate the rotary counter clockwise event."
inputbroker_event_press, "Input broker event press", "Generate input event on Press of this kind."
updown1_enabled, "Up down encoder enabled", "Enable the up / down encoder."
enabled, "Включен", "Включает модуль."
allow_input_source, "Источник ввода", "Введите источники событий, принятые модулем сохраненных сообщений."
send_bell, "Послать колокольчик", "Отправляет символ колокольчика с каждым сообщением."
[module.audio]
title, "Аудио"
codec2_enabled, "Включено", "Включает модуль."
ptt_pin, "PTT GPIO", "The GPIO to use for the Push-To-Talk button. The default is GPIO 39 on the ESP32."
bitrate, "Audio bitrate/codec mode", "The bitrate to use for audio."
i2s_ws, "I2S word select", "The GPIO to use for the WS signal in the I2S interface."
i2s_sd, "I2S data IN", "The GPIO to use for the SD signal in the I2S interface."
i2s_din, "I2S data OUT", "The GPIO to use for the DIN signal in the I2S interface."
i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface."
[module.remote_hardware]
title, "Remote Hardware"
enabled, "Включен", "Включает модуль."
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."
[module.neighbor_info]
title, "Информация о соседях"
enabled, "Включен", "Включает модуль."
update_interval, "Update interval", "How often in seconds the neighbor info is sent to the mesh. This cannot be set lower than 4 hours (14400 seconds). The default is 6 hours (21600 seconds)."
transmit_over_lora, "Transmit over LoRa", "Available from firmware 2.5.13 and higher. By default, neighbor info will only be sent to MQTT and a connected app. If enabled, the neighbor info will be sent on the primary channel over LoRa. Only available when the primary channel is not the public channel with default key and name."
[module.ambient_lighting]
title, "Ambient Lighting"
led_state, "LED state", "Sets the LED to on or Off."
current, "Current", "Sets the current for the LED output. Default is 10."
red, "Red", "Sets the red LED level. Values are 0-255."
green, "Green", "Sets the green LED level. Values are 0-255."
blue, "Blue", "Sets the blue LED level. Values are 0-255."
[module.detection_sensor]
title, "Detection Sensor"
enabled, "Включен", "Включает модуль."
minimum_broadcast_secs, "Minimum broadcast interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
state_broadcast_secs, "State broadcast interval", "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes, When set to 0, only state changes will be broadcasted, Works as a sort of status heartbeat for peace of mind."
send_bell, "Send bell", "Send ASCII bell with alert message. Useful for triggering ext. notification on bell name."
name, "Friendly name", "Used to format the message sent to mesh. Example: A name 'Motion' would result in a message 'Motion detected'. Maximum length of 20 characters."
monitor_pin, "Monitor pin", "The GPIO pin to monitor for state changes."
detection_trigger_type, "Detection triggered high", "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)."
use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin."
[module.paxcounter]
title, "Счетчик посещений"
enabled, "Включен", "Включает модуль."
paxcounter_update_interval, "Интервал обновления", "Интервал в секундах, с которым мы можем отправлять сообщение в сеть при обнаружении изменения состояния."
Wi-Fi_threshold, "Порог Wi-Fi", "Порог WiFi RSSI. По умолчанию -80"
ble_threshold, "Порог BLE", "Порог BLE RSSI. По умолчанию -80"

View File

@@ -1,196 +1,105 @@
import logging
import os
import platform
import shutil
import time
import subprocess
import threading
from typing import Any, Dict, Optional
# Debounce notification sounds so a burst of queued messages only plays once.
_SOUND_DEBOUNCE_SECONDS = 0.8
_sound_timer: Optional[threading.Timer] = None
_sound_timer_lock = threading.Lock()
_last_sound_request = 0.0
def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
"""Schedule a notification sound after a short quiet period.
If more messages arrive before the delay elapses, the timer is reset.
This prevents playing a sound for each message when a backlog flushes.
"""
global _sound_timer, _last_sound_request
now = time.monotonic()
with _sound_timer_lock:
_last_sound_request = now
# Cancel any previously scheduled sound.
if _sound_timer is not None:
try:
_sound_timer.cancel()
except Exception:
pass
_sound_timer = None
def _fire(expected_request_time: float) -> None:
# Only play if nothing newer has been scheduled.
with _sound_timer_lock:
if expected_request_time != _last_sound_request:
return
play_sound()
_sound_timer = threading.Timer(delay, _fire, args=(now,))
_sound_timer.daemon = True
_sound_timer.start()
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
)
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
draw_messages_window,
draw_channel_list,
add_notification,
)
from contact.utilities.db_handler import (
save_message_to_db,
maybe_store_nodeinfo_in_db,
get_name_from_database,
update_node_info_in_db,
)
from contact.utilities.utils import refresh_node_list
from datetime import datetime
from contact.ui.contact_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification
from contact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
import contact.globals as globals
def play_sound():
try:
system = platform.system()
sound_path = None
executable = None
from datetime import datetime
if system == "Darwin": # macOS
sound_path = "/System/Library/Sounds/Ping.aiff"
executable = "afplay"
def on_receive(packet, node_state):
elif system == "Linux":
ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga"
wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback
if shutil.which("paplay") and os.path.exists(ogg_path):
executable = "paplay"
sound_path = ogg_path
elif shutil.which("ffplay") and os.path.exists(ogg_path):
executable = "ffplay"
sound_path = ogg_path
elif shutil.which("aplay") and os.path.exists(wav_path):
executable = "aplay"
sound_path = wav_path
else:
logging.warning("No suitable sound player or sound file found on Linux")
if executable and sound_path:
cmd = [executable, sound_path]
if executable == "ffplay":
cmd = [executable, "-nodisp", "-autoexit", sound_path]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
except subprocess.CalledProcessError as e:
logging.error(f"Sound playback failed: {e}")
except Exception as e:
logging.error(f"Unexpected error: {e}")
def on_receive(packet: Dict[str, Any], interface: Any) -> None:
"""
Handles an incoming packet from a Meshtastic interface.
Args:
packet: The received Meshtastic packet as a dictionary.
interface: The Meshtastic interface instance that received the packet.
"""
with app_state.lock:
with globals.lock:
# Update packet log
ui_state.packet_buffer.append(packet)
if len(ui_state.packet_buffer) > 20:
globals.packet_buffer.append(packet)
if len(globals.packet_buffer) > 20:
# Trim buffer to 20 packets
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
if ui_state.display_log:
draw_packetlog_win()
if ui_state.current_window == 4:
menu_state.need_redraw = True
globals.packet_buffer = globals.packet_buffer[-20:]
if globals.display_log:
draw_packetlog_win(node_state)
try:
if "decoded" not in packet:
if 'decoded' not in packet:
return
# Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list()
if changed:
if(changed):
draw_node_list()
if packet["decoded"]["portnum"] == "NODEINFO_APP":
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
maybe_store_nodeinfo_in_db(packet)
if packet['decoded']['portnum'] == 'NODEINFO_APP':
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
maybe_store_nodeinfo_in_db(packet, node_state)
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":
schedule_notification_sound()
message_bytes = packet["decoded"]["payload"]
message_string = message_bytes.decode("utf-8")
elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
message_bytes = packet['decoded']['payload']
message_string = message_bytes.decode('utf-8')
refresh_channels = False
refresh_messages = False
if packet.get("channel"):
channel_number = packet["channel"]
if packet.get('channel'):
channel_number = packet['channel']
else:
channel_number = 0
if packet["to"] == interface_state.myNodeNum:
if packet["from"] in ui_state.channel_list:
if packet['to'] == globals.myNodeNum:
if packet['from'] in globals.channel_list:
pass
else:
ui_state.channel_list.append(packet["from"])
if packet["from"] not in ui_state.all_messages:
ui_state.all_messages[packet["from"]] = []
update_node_info_in_db(packet["from"], chat_archived=False)
globals.channel_list.append(packet['from'])
if(packet['from'] not in globals.all_messages):
globals.all_messages[packet['from']] = []
update_node_info_in_db(packet['from'], chat_archived=False)
refresh_channels = True
channel_number = ui_state.channel_list.index(packet["from"])
channel_number = globals.channel_list.index(packet['from'])
channel_id = ui_state.channel_list[channel_number]
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]:
add_notification(channel_number)
refresh_channels = True
else:
refresh_messages = True
# Add received message to the messages list
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
message_from_id = packet['from']
message_from_string = get_name_from_database(message_from_id, node_state, type='short') + ":"
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
if globals.channel_list[channel_number] not in globals.all_messages:
globals.all_messages[globals.channel_list[channel_number]] = []
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
# Retrieve the last timestamp if available
channel_messages = globals.all_messages[globals.channel_list[channel_number]]
if channel_messages:
# Check the last entry for a timestamp
for entry in reversed(channel_messages):
if entry[0].startswith("--"):
last_hour = entry[0].strip("- ").strip()
break
else:
last_hour = None
else:
last_hour = None
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
globals.all_messages[globals.channel_list[channel_number]].append((f"-- {current_hour} --", ""))
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string} ", message_string))
if refresh_channels:
draw_channel_list()
draw_channel_list(node_state)
if refresh_messages:
draw_messages_window(True)
draw_messages_window(node_state, True)
save_message_to_db(channel_id, message_from_id, message_string)
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
except KeyError as e:
logging.error(f"Error processing packet: {e}")

View File

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

View File

@@ -7,9 +7,6 @@ import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.i18n import t
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
@@ -17,33 +14,28 @@ from contact.utilities.arg_parser import setup_parser
from contact.utilities.interfaces import initialize_interface
def main(stdscr: curses.window) -> None:
def main(stdscr):
output_capture = io.StringIO()
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
ensure_min_rows(stdscr)
draw_splash(stdscr)
curses.curs_set(0)
stdscr.keypad(True)
parser = setup_parser()
args = parser.parse_args()
interface = initialize_interface(args)
node_state.interface = initialize_interface(args)
if interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input(
t("ui.confirm.region_unset", default="Your region is UNSET. Set it now?"),
"Yes",
["Yes", "No"],
)
if node_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)
interface.close()
interface = initialize_interface(args)
set_region(node_state.interface)
node_state.interface.close()
node_state.interface = initialize_interface(args)
stdscr.clear()
stdscr.refresh()
settings_menu(stdscr, interface)
settings_menu(stdscr, node_state)
except Exception as e:
console_output = output_capture.getvalue()
@@ -53,31 +45,16 @@ def main(stdscr: curses.window) -> None:
raise
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
while True:
rows, _ = stdscr.getmaxyx()
if rows >= min_rows:
return
dialog(
t("ui.dialog.resize_title", default="Resize Terminal"),
t(
"ui.dialog.resize_body",
default="Please resize the terminal to at least {rows} rows.",
rows=min_rows,
),
)
curses.update_lines_cols()
stdscr.clear()
stdscr.refresh()
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
filename=config.log_file_path,
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s",
format="%(asctime)s - %(levelname)s - %(message)s"
)
if __name__ == "__main__":
from contact.ui.ui_state import NodeState
node_state = NodeState()
log_file = config.log_file_path
log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes
@@ -93,4 +70,4 @@ if __name__ == "__main__":
except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1) # Exit with an error code
sys.exit(1) # Exit with an error code

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,125 +1,73 @@
import base64
import curses
import ipaddress
import logging
import os
import re
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 transform_menu_path
from contact.utilities.i18n import t
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import (
get_repeated_input,
get_text_input,
get_fixed32_input,
get_list_input,
get_admin_key_input,
)
from contact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.colors import get_color
from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
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
translation_file = config.get_localisation_file(config.language)
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 _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0 and later use an is_repeated property, while older versions compare against the label field.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def reload_translations() -> None:
global translation_file, field_mapping, help_text
translation_file = config.get_localisation_file(config.language)
field_mapping, help_text = parse_ini_file(translation_file)
def get_translated_header(menu_path: List[str]) -> str:
if not menu_path:
return ""
transformed_path = transform_menu_path(menu_path)
translated_parts = []
for idx, part in enumerate(menu_path):
if idx == 0:
translated_parts.append(field_mapping.get(part, part))
continue
full_key = ".".join(transformed_path[:idx])
translated_parts.append(field_mapping.get(full_key, part))
return " > ".join(translated_parts)
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):
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()
max_menu_height = curses.LINES
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
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 = get_translated_header(menu_state.menu_path)
if len(header) > w - 4:
header = header[: w - 7] + "..."
header = " > ".join(word.title() for word in menu_state.menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
transformed_path = transform_menu_path(menu_state.menu_path)
@@ -127,137 +75,279 @@ def display_menu() -> tuple[object, object]:
for idx, option in enumerate(menu_state.current_menu):
field_info = menu_state.current_menu[option]
current_value = field_info[1] if isinstance(field_info, tuple) else ""
full_key = ".".join(transformed_path + [option])
full_key = '.'.join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}:
if isinstance(current_value, int):
try:
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
elif isinstance(current_value, str) and current_value.isdigit():
try:
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
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)
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:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
except curses.error:
pass
if menu_state.show_save_option:
save_position = menu_height - 2
save_label = t("ui.save_changes", default=save_option)
menu_win.addstr(
save_position,
(w - len(save_label)) // 2,
save_label,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
menu_win.addstr(save_position, (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(
menu_state.start_index[-1],
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
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)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
draw_arrows(menu_win, visible_height, max_index, menu_state)
return menu_win, menu_pad
def draw_help_window(
menu_start_y: int,
menu_start_x: int,
menu_height: int,
max_help_lines: int,
transformed_path: List[str],
) -> None:
def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, transformed_path, menu_state):
global help_win
if "help_win" not in globals():
if 'help_win' not in globals():
help_win = None # Initialize if it does not exist
selected_option = (
list(menu_state.current_menu.keys())[menu_state.selected_index] if menu_state.current_menu else None
)
selected_option = list(menu_state.current_menu.keys())[menu_state.selected_index] if menu_state.current_menu else None
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 = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x)
def update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, help_x):
"""Handles rendering the help window consistently."""
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines)
def get_input_type_for_field(field) -> type:
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
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
# Ensure help window does not exceed screen size
if help_y + help_height > curses.LINES:
help_y = curses.LINES - help_height
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, width, help_y, help_x)
else:
return str
help_win.erase()
help_win.refresh()
help_win.resize(help_height, width)
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
help_win.border()
for idx, line_segments in enumerate(wrapped_help):
x_pos = 2 # Start after border
for text, color, bold, underline in line_segments:
try:
attr = get_color(color, bold=bold, underline=underline)
help_win.addstr(1 + idx, x_pos, text, attr)
x_pos += len(text)
except curses.error:
pass # Prevent crashes
help_win.refresh()
return help_win
def settings_menu(stdscr: object, interface: object) -> None:
def get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_lines):
"""Fetches and formats help text for display, ensuring it fits within the allowed lines."""
full_help_key = '.'.join(transformed_path + [selected_option]) if selected_option else None
help_content = help_text.get(full_help_key, "No help available.")
wrap_width = max(width - 6, 10) # Ensure a valid wrapping width
# Color replacements
color_mappings = {
r'\[warning\](.*?)\[/warning\]': ('settings_warning', True, False), # Red for warnings
r'\[note\](.*?)\[/note\]': ('settings_note', True, False), # Green for notes
r'\[underline\](.*?)\[/underline\]': ('settings_default', False, True), # Underline
r'\\033\[31m(.*?)\\033\[0m': ('settings_warning', True, False), # Red text
r'\\033\[32m(.*?)\\033\[0m': ('settings_note', True, False), # Green text
r'\\033\[4m(.*?)\\033\[0m': ('settings_default', False, True) # Underline
}
def extract_ansi_segments(text):
"""Extracts and replaces ANSI color codes, ensuring spaces are preserved."""
matches = []
last_pos = 0
pattern_matches = []
# Find all matches and store their positions
for pattern, (color, bold, underline) in color_mappings.items():
for match in re.finditer(pattern, text):
pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline))
# Sort matches by start position to process sequentially
pattern_matches.sort(key=lambda x: x[0])
for start, end, content, color, bold, underline in pattern_matches:
# Preserve non-matching text including spaces
if last_pos < start:
segment = text[last_pos:start]
matches.append((segment, "settings_default", False, False))
# Append the colored segment
matches.append((content, color, bold, underline))
last_pos = end
# Preserve any trailing text
if last_pos < len(text):
matches.append((text[last_pos:], "settings_default", False, False))
return matches
def wrap_ansi_text(segments, wrap_width):
"""Wraps text while preserving ANSI formatting and spaces."""
wrapped_lines = []
line_buffer = []
line_length = 0
for text, color, bold, underline in segments:
words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately
for word in words:
word_length = len(word)
if line_length + word_length > wrap_width and word.strip():
# If the word (ignoring spaces) exceeds width, wrap the line
wrapped_lines.append(line_buffer)
line_buffer = []
line_length = 0
line_buffer.append((word, color, bold, underline))
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer)
return wrapped_lines
raw_lines = help_content.split("\\n") # Preserve new lines
wrapped_help = []
for raw_line in raw_lines:
color_segments = extract_ansi_segments(raw_line)
wrapped_segments = wrap_ansi_text(color_segments, wrap_width)
wrapped_help.extend(wrapped_segments)
pass
# Trim and add ellipsis if needed
if len(wrapped_help) > max_lines:
wrapped_help = wrapped_help[:max_lines]
wrapped_help[-1].append(("...", "settings_default", False, False))
return wrapped_help
def move_highlight(old_idx, options, menu_win, menu_pad, help_win, help_text, max_help_lines, menu_state):
if old_idx == menu_state.selected_index: # No-op
return
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
# Adjust menu_state.start_index only when moving out of visible range
if menu_state.selected_index == max_index and menu_state.show_save_option:
pass
elif menu_state.selected_index < menu_state.start_index[-1]: # Moving above the visible area
menu_state.start_index[-1] = menu_state.selected_index
elif menu_state.selected_index >= menu_state.start_index[-1] + visible_height: # Moving below the visible area
menu_state.start_index[-1] = menu_state.selected_index - visible_height
pass
# Ensure menu_state.start_index is within bounds
menu_state.start_index[-1] = max(0, min(menu_state.start_index[-1], max_index - visible_height + 1))
# Clear old selection
if menu_state.show_save_option and old_idx == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else:
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
# Highlight new selection
if menu_state.show_save_option and menu_state.selected_index == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True))
else:
menu_pad.chgat(menu_state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[menu_state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(menu_state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
# Update help window
transformed_path = transform_menu_path(menu_state.menu_path)
selected_option = options[menu_state.selected_index] if menu_state.selected_index < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1])
draw_arrows(menu_win, visible_height, max_index, menu_state)
def draw_arrows(win, visible_height, max_index, menu_state):
# vh = visible_height + (1 if show_save_option else 0)
mi = max_index - (2 if menu_state.show_save_option else 0)
if visible_height < mi:
if menu_state.start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - menu_state.start_index[-1] >= visible_height + (0 if menu_state.show_save_option else 1) :
win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
def settings_menu(stdscr, node_state):
curses.update_lines_cols()
menu = generate_menu_from_protobuf(interface)
menu = generate_menu_from_protobuf(node_state)
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
modified_settings = {}
menu_state.need_redraw = True
modified_settings = {}
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
@@ -265,72 +355,41 @@ def settings_menu(stdscr: object, interface: object) -> None:
if key == curses.KEY_UP:
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
move_highlight(
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, menu_state)
elif key == curses.KEY_DOWN:
old_selected_index = menu_state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
move_highlight(
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, menu_state)
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
menu_state.selected_index = max_index
move_highlight(
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, menu_state)
elif key == curses.KEY_RIGHT or key == ord("\n"):
menu_state.need_redraw = True
elif key == curses.KEY_RIGHT or key == ord('\n'):
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)
save_changes(node_state, modified_settings, menu_state)
modified_settings.clear()
logging.info("Changes Saved")
@@ -348,12 +407,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
break
elif selected_option == "Export Config File":
filename = get_text_input(
t("ui.prompt.config_filename", default="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()
@@ -362,19 +416,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
filename += ".yaml"
try:
config_text = config_export(interface)
config_text = config_export(node_state)
yaml_file_path = os.path.join(config_folder, filename)
if os.path.exists(yaml_file_path):
overwrite = get_list_input(
t(
"ui.confirm.overwrite_file",
default="{filename} already exists. Overwrite?",
filename=filename,
),
None,
["Yes", "No"],
)
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
if overwrite == "No":
logging.info("Export cancelled: User chose not to overwrite.")
menu_state.start_index.pop()
@@ -383,8 +429,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(t("ui.dialog.config_saved_title", default="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:
@@ -395,106 +440,70 @@ def settings_menu(stdscr: object, interface: object) -> None:
logging.error(f"Unexpected error: {e}")
menu_state.start_index.pop()
continue
elif selected_option == "Load Config File":
# Check if folder exists and is not empty
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
dialog("", t("ui.dialog.no_config_files", default=" 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("", t("ui.dialog.no_config_files", default=" 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(
t("ui.prompt.choose_config_file", default="Choose a config file"), None, file_list
)
filename = get_list_input("Choose a config file", None, file_list)
if filename:
file_path = os.path.join(config_folder, filename)
overwrite = get_list_input(
t(
"ui.confirm.load_config_file",
default="Are you sure you want to load {filename}?",
filename=filename,
),
None,
["Yes", "No"],
)
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
if overwrite == "Yes":
config_import(interface, file_path)
config_import(node_state, file_path)
menu_state.start_index.pop()
continue
elif selected_option == "Config URL":
current_value = interface.localNode.getURL()
new_value = get_text_input(
t(
"ui.prompt.config_url_current",
default="Config URL is currently: {value}",
value=current_value,
),
None,
str,
)
current_value = node_state.interface.localNode.getURL()
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(
t("ui.confirm.load_config_url", default="Are you sure you want to load this config?"),
None,
["Yes", "No"],
)
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
if overwrite == "Yes":
interface.localNode.setURL(new_value)
node_state.interface.localNode.setURL(new_value)
logging.info(f"New Config URL sent to node")
menu_state.start_index.pop()
continue
elif selected_option == "Reboot":
confirmation = get_list_input(
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
)
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.reboot()
node_state.interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
menu_state.start_index.pop()
continue
elif selected_option == "Reset Node DB":
confirmation = get_list_input(
t("ui.confirm.reset_node_db", default="Are you sure you want to Reset Node DB?"),
None,
["Yes", "No"],
)
confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.resetNodeDb()
node_state.interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
menu_state.start_index.pop()
continue
elif selected_option == "Shutdown":
confirmation = get_list_input(
t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"]
)
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.shutdown()
node_state.interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu")
menu_state.start_index.pop()
continue
elif selected_option == "Factory Reset":
confirmation = get_list_input(
t("ui.confirm.factory_reset", default="Are you sure you want to Factory Reset?"),
None,
["Yes", "No"],
)
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.factoryReset()
node_state.interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
menu_state.start_index.pop()
continue
@@ -505,38 +514,32 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.menu_path.append("App Settings")
menu_state.menu_index.append(menu_state.selected_index)
json_editor(stdscr, menu_state) # Open the App Settings menu
reload_translations()
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
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):
field, current_value = field_info
# Transform the menu path to get the full key
transformed_path = transform_menu_path(menu_state.menu_path)
full_key = ".".join(transformed_path + [selected_option])
full_key = '.'.join(transformed_path + [selected_option])
# Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option)
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
)
if selected_option in ['longName', 'shortName', 'isLicensed']:
if selected_option in ['longName', 'shortName']:
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
elif selected_option == "isLicensed":
new_value = get_list_input(
f"{human_readable_name} is currently: {current_value}",
str(current_value),
["True", "False"],
)
elif selected_option == 'isLicensed':
new_value = get_list_input(f"{human_readable_name} is currently: {current_value}", str(current_value), ["True", "False"])
new_value = new_value == "True"
menu_state.current_menu[selected_option] = (field, new_value)
@@ -545,14 +548,12 @@ 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
)
elif selected_option in ['latitude', 'longitude', 'altitude']:
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
for option in ["latitude", "longitude", "altitude"]:
for option in ['latitude', 'longitude', 'altitude']:
if option in menu_state.current_menu:
modified_settings[option] = menu_state.current_menu[option][1]
@@ -564,14 +565,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.start_index.pop()
elif field.type == 8: # Handle boolean type
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
if new_value == "Not Set":
pass # Leave it as-is
else:
new_value = new_value == "True" or new_value is True
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
new_value = new_value == "True" or new_value is True
menu_state.start_index.pop()
elif _is_repeated_field(field): # Handle repeated field - Not currently used
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else new_value.split(", ")
menu_state.start_index.pop()
@@ -582,47 +580,30 @@ def settings_menu(stdscr: object, interface: object) -> None:
new_value = enum_options.get(new_value_name, current_value)
menu_state.start_index.pop()
elif field.type == 7: # Field type 7 corresponds to FIXED32
elif field.type == 7: # Field type 7 corresponds to FIXED32
new_value = get_fixed32_input(current_value)
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
)
elif field.type == 13: # Field type 13 corresponds to UINT32
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else int(new_value)
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
)
elif field.type == 2: # Field type 13 corresponds to INT64
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else float(new_value)
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:
@@ -636,47 +617,21 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.menu_index.append(menu_state.selected_index)
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(
t(
"ui.confirm.save_before_exit_section",
default="You have unsaved changes in {section}. Save before exiting?",
section=current_section,
),
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:
@@ -686,25 +641,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.current_menu = menu_state.current_menu.get(step, {})
menu_state.selected_index = menu_state.menu_index.pop()
menu_state.start_index.pop()
elif key == 27: # Escape key
menu_win.erase()
menu_win.refresh()
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")
def set_region(node_state):
node = node_state.interface.getNode('^local')
device_config = node.localConfig
lora_descriptor = device_config.lora.DESCRIPTOR
@@ -714,12 +658,10 @@ def set_region(interface: object) -> None:
regions = list(region_name_to_number.keys())
new_region_name = get_list_input(
t("ui.prompt.select_region", default="Select your region:"), "UNSET", regions
)
new_region_name = get_list_input('Select your region:', 'UNSET', regions)
# Convert region name to corresponding enum number
new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found
node.localConfig.lora.region = new_region_number
node.writeConfig("lora")
node.writeConfig("lora")

View File

@@ -1,120 +1,27 @@
import json
import logging
import json
import os
from typing import Dict, List, Optional
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"
# 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 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/")
localisations_dir = os.path.join(parent_dir, "localisations")
def get_localisation_options(localisations_path: Optional[str] = None) -> List[str]:
"""
Return available localisation codes from the localisations folder.
"""
localisations_path = localisations_path or localisations_dir
if not os.path.isdir(localisations_path):
return []
options = []
for filename in os.listdir(localisations_path):
if filename.startswith(".") or not filename.endswith(".ini"):
continue
options.append(os.path.splitext(filename)[0])
return sorted(options)
def get_localisation_file(language: str, localisations_path: Optional[str] = None) -> str:
"""
Return a valid localisation file path, falling back to a default when missing.
"""
localisations_path = localisations_path or localisations_dir
available = get_localisation_options(localisations_path)
if not available:
return os.path.join(localisations_path, "en.ini")
normalized = (language or "").strip().lower()
if normalized.endswith(".ini"):
normalized = normalized[:-4]
if normalized in available:
return os.path.join(localisations_path, f"{normalized}.ini")
fallback = "en" if "en" in available else available[0]
return os.path.join(localisations_path, f"{fallback}.ini")
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
def format_json_single_line_arrays(data, indent=4):
"""
Formats JSON with arrays on a single line while keeping other elements properly indented.
"""
def format_value(value: object, current_indent: int) -> str:
def format_value(value, current_indent):
if isinstance(value, dict):
items = []
for key, val in value.items():
items.append(f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}')
items.append(
f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}'
)
return "{\n" + ",\n".join(items) + f"\n{' ' * (current_indent - indent)}}}"
elif isinstance(value, list):
return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]"
@@ -123,9 +30,8 @@ def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) ->
return format_value(data, indent)
# Recursive function to check and update nested dictionaries
def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool:
def update_dict(default, actual):
updated = False
for key, value in default.items():
if key not in actual:
@@ -136,8 +42,7 @@ def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool:
updated = update_dict(value, actual[key]) or updated
return updated
def initialize_config() -> Dict[str, object]:
def initialize_config():
COLOR_CONFIG_DARK = {
"default": ["white", "black"],
"background": [" ", "black"],
@@ -162,7 +67,7 @@ def initialize_config() -> Dict[str, object]:
"settings_warning": ["red", "black"],
"settings_note": ["green", "black"],
"node_favorite": ["green", "black"],
"node_ignored": ["red", "black"],
"node_ignored": ["red", "black"]
}
COLOR_CONFIG_LIGHT = {
"default": ["black", "white"],
@@ -188,7 +93,7 @@ def initialize_config() -> Dict[str, object]:
"settings_warning": ["red", "white"],
"settings_note": ["green", "white"],
"node_favorite": ["green", "white"],
"node_ignored": ["red", "white"],
"node_ignored": ["red", "white"]
}
COLOR_CONFIG_GREEN = {
"default": ["green", "black"],
@@ -215,23 +120,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"]
}
available_languages = get_localisation_options()
default_language = "en" if "en" in available_languages else (available_languages[0] if available_languages else "en")
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,
"language": default_language,
"message_prefix": ">>",
"sent_message_prefix": ">> Sent",
"notification_symbol": "*",
"notification_sound": "True",
"ack_implicit_str": "[◌]",
"ack_str": "[✓]",
"nak_str": "[x]",
@@ -240,7 +137,7 @@ def initialize_config() -> Dict[str, object]:
"theme": "dark",
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN
}
if not os.path.exists(json_file_path):
@@ -257,39 +154,30 @@ def initialize_config() -> Dict[str, object]:
# Update the JSON file if any variables were missing
if updated:
formatted_json = format_json_single_line_arrays(loaded_config)
with open(json_file_path, "w", encoding="utf-8") as json_file:
json_file.write(formatted_json)
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
formatted_json = format_json_single_line_arrays(loaded_config)
with open(json_file_path, "w", encoding="utf-8") as json_file:
json_file.write(formatted_json)
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
return loaded_config
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
def assign_config_variables(loaded_config):
# 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, language
global node_sort, notification_sound
global theme, COLOR_CONFIG
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")
language = loaded_config["language"]
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"]
@@ -297,6 +185,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
@@ -305,14 +194,13 @@ assign_config_variables(loaded_config)
if __name__ == "__main__":
logging.basicConfig(
filename="default_config.log",
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s",
filename="default_config.log",
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s"
)
print("\nLoaded Configuration:")
print(f"Database File Path: {db_file_path}")
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}")
@@ -320,4 +208,4 @@ if __name__ == "__main__":
print(f"ACK String: {ack_str}")
print(f"NAK String: {nak_str}")
print(f"ACK Unknown String: {ack_unknown_str}")
print(f"Color Config: {COLOR_CONFIG}")
print(f"Color Config: {COLOR_CONFIG}")

View File

@@ -1,168 +1,43 @@
import curses
from contact.utilities.i18n import t_text
from contact.ui.colors import get_color
from contact.ui.nav_utils import draw_main_arrows
from contact.utilities.singleton import menu_state, ui_state
def dialog(stdscr, title, message):
height, width = stdscr.getmaxyx()
def dialog(title: str, message: str) -> None:
title = t_text(title)
message = t_text(message)
"""Display a dialog with a title and message."""
previous_window = ui_state.current_window
ui_state.current_window = 4
curses.update_lines_cols()
height, width = curses.LINES, curses.COLS
# Parse message into lines and calculate dimensions
message_lines = message.splitlines() or [""]
max_line_length = max(len(l) for l in message_lines)
# Desired size
# Calculate dialog dimensions
max_line_lengh = 0
message_lines = message.splitlines()
for l in message_lines:
max_line_length = max(len(l), max_line_lengh)
dialog_width = max(len(title) + 4, max_line_length + 4)
desired_height = len(message_lines) + 4
# Clamp dialog size to the screen (leave a 1-cell margin if possible)
max_w = max(10, width - 2)
max_h = max(6, height - 2)
dialog_width = min(dialog_width, max_w)
dialog_height = min(desired_height, max_h)
x = max(0, (width - dialog_width) // 2)
y = max(0, (height - dialog_height) // 2)
# Ensure we have a start index slot for this dialog window id (4)
# ui_state.start_index is used by draw_main_arrows()
try:
while len(ui_state.start_index) <= 4:
ui_state.start_index.append(0)
except Exception:
# If start_index isn't list-like, fall back to an attribute
if not hasattr(ui_state, "start_index"):
ui_state.start_index = [0, 0, 0, 0, 0]
def visible_message_rows() -> int:
# Rows available for message text inside the border, excluding title row and OK row.
# Layout:
# row 0: title
# rows 1..(dialog_height-3): message viewport (with arrows drawn on a subwindow)
# row dialog_height-2: OK button
# So message viewport height is dialog_height - 3 - 1 + 1 = dialog_height - 3
return max(1, dialog_height - 4)
def draw_window():
win.erase()
win.bkgd(get_color("background"))
win.attrset(get_color("window_frame"))
win.border(0)
# Title
try:
win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default"))
except curses.error:
pass
# Message viewport
viewport_h = visible_message_rows()
start = ui_state.start_index[4]
start = max(0, min(start, max(0, len(message_lines) - viewport_h)))
ui_state.start_index[4] = start
# Create a subwindow covering the message region so draw_main_arrows() doesn't collide with the OK row
msg_win = win.derwin(viewport_h + 2, dialog_width - 2, 1, 1)
msg_win.erase()
for i in range(viewport_h):
idx = start + i
if idx >= len(message_lines):
break
line = message_lines[idx]
# Hard-trim lines that don't fit
trimmed = line[: max(0, dialog_width - 6)]
msg_x = max(0, ((dialog_width - 2) - len(trimmed)) // 2)
try:
msg_win.addstr(1 + i, msg_x, trimmed, get_color("settings_default"))
except curses.error:
pass
# Draw arrows only when scrolling is needed
if len(message_lines) > viewport_h:
draw_main_arrows(msg_win, len(message_lines) - 1, window=4)
else:
# Clear arrow positions if not needed
try:
h, w = msg_win.getmaxyx()
msg_win.addstr(1, w - 2, " ", get_color("settings_default"))
msg_win.addstr(h - 2, w - 2, " ", get_color("settings_default"))
except curses.error:
pass
msg_win.noutrefresh()
# OK button
ok_text = " Ok "
try:
win.addstr(
dialog_height - 2,
(dialog_width - len(ok_text)) // 2,
ok_text,
get_color("settings_default", reverse=True),
)
except curses.error:
pass
win.noutrefresh()
curses.doupdate()
dialog_height = len(message_lines) + 4
x = (width - dialog_width) // 2
y = (height - dialog_height) // 2
# Create dialog window
win = curses.newwin(dialog_height, dialog_width, y, x)
win.keypad(True)
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
curses.update_lines_cols()
height, width = curses.LINES, curses.COLS
draw_window()
# Close dialog
ok_selected = True
if char in (27, curses.KEY_LEFT): # Esc or Left arrow
# 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 ok_selected and char in (curses.KEY_ENTER, 10, 13, 32):
win.erase()
win.refresh()
ui_state.current_window = previous_window
return
if char == -1:
continue
# Scroll if the dialog is clipped vertically
viewport_h = visible_message_rows()
if len(message_lines) > viewport_h:
start = ui_state.start_index[4]
max_start = max(0, len(message_lines) - viewport_h)
if char in (curses.KEY_UP, ord("k")):
ui_state.start_index[4] = max(0, start - 1)
draw_window()
elif char in (curses.KEY_DOWN, ord("j")):
ui_state.start_index[4] = min(max_start, start + 1)
draw_window()
elif char == curses.KEY_PPAGE: # Page up
ui_state.start_index[4] = max(0, start - viewport_h)
draw_window()
elif char == curses.KEY_NPAGE: # Page down
ui_state.start_index[4] = min(max_start, start + viewport_h)
draw_window()

View File

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

View File

@@ -1,561 +0,0 @@
import curses
import re
import unicodedata
from typing import Any, Optional, List, Dict
from wcwidth import wcwidth, wcswidth
from contact.ui.colors import get_color
from contact.utilities.i18n import t
from contact.utilities.control_utils import transform_menu_path
from contact.utilities.singleton import interface_state, ui_state
ZWJ = "\u200d"
def get_node_color(node_index: int, reverse: bool = False):
node_num = ui_state.node_list[node_index]
node = interface_state.interface.nodesByNum.get(node_num, {})
if node.get("isFavorite"):
return get_color("node_favorite", reverse=reverse)
elif node.get("isIgnored"):
return get_color("node_ignored", reverse=reverse)
return get_color("settings_default", reverse=reverse)
# Aliases
Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment]
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
def get_save_option_label() -> str:
return t("ui.save_changes", default=save_option)
def move_highlight(
old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any
) -> None:
show_save_option = None
start_index = [0]
help_text = None
max_help_lines = 0
help_win = None
if "help_win" in kwargs:
help_win = kwargs["help_win"]
if "menu_state" in kwargs:
new_idx = kwargs["menu_state"].selected_index
show_save_option = kwargs["menu_state"].show_save_option
start_index = kwargs["menu_state"].start_index
transformed_path = transform_menu_path(kwargs["menu_state"].menu_path)
else:
new_idx = kwargs["selected_index"]
transformed_path = []
if "help_text" in kwargs:
help_text = kwargs["help_text"]
if "max_help_lines" in kwargs:
max_help_lines = kwargs["max_help_lines"]
if not options:
return
if old_idx == new_idx: # No-op
return
max_index = len(options) + (1 if show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)
# Adjust menu_state.start_index only when moving out of visible range
if new_idx == max_index and show_save_option:
pass
elif new_idx < start_index[-1]: # Moving above the visible area
start_index[-1] = new_idx
elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area
start_index[-1] = new_idx - visible_height
# Ensure menu_state.start_index is within bounds
start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1))
# Clear old selection
if show_save_option and old_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
save_label = get_save_option_label()
menu_win.chgat(
win_h - 2, (win_w - len(save_label)) // 2, len(save_label), get_color("settings_save")
)
elif 0 <= old_idx < len(options):
menu_pad.chgat(
old_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive")
if options[old_idx] in sensitive_settings
else get_color("settings_default")
),
)
# Highlight new selection
if show_save_option and new_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
save_label = get_save_option_label()
menu_win.chgat(
win_h - 2,
(win_w - len(save_label)) // 2,
len(save_label),
get_color("settings_save", reverse=True),
)
elif 0 <= new_idx < len(options):
menu_pad.chgat(
new_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive", reverse=True)
if options[new_idx] in sensitive_settings
else get_color("settings_default", reverse=True)
),
)
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(
start_index[-1],
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
# Update help window only if help_text is populated
selected_option = options[new_idx] if new_idx < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
if help_win:
win_h, win_w = menu_win.getmaxyx()
help_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
win_w,
help_y,
menu_win.getbegyx()[1],
)
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)
def draw_arrows(
win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool
) -> None:
mi = max_index - (2 if show_save_option else 0)
if visible_height < mi:
if start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1):
win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
def update_help_window(
help_win: object, # curses window or None
help_text: Dict[str, str],
transformed_path: List[str],
selected_option: Optional[str],
max_help_lines: int,
width: int,
help_y: int,
help_x: int,
) -> object: # returns a curses window
"""Handles rendering the help window consistently."""
# Clamp target position and width to the current terminal size
help_x = max(0, help_x)
help_y = max(0, help_y)
# Ensure requested width fits on screen from help_x
max_w_from_x = max(1, curses.COLS - help_x)
safe_width = min(width, max_w_from_x)
# Always leave a minimal border area; enforce a minimum usable width of 3
safe_width = max(3, safe_width)
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_width, max_help_lines)
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
# Re-clamp Y to keep the window visible
if help_y + help_height > curses.LINES:
help_y = max(0, curses.LINES - help_height)
# If width would overflow the screen, shrink it
if help_x + safe_width > curses.COLS:
safe_width = max(3, curses.COLS - help_x)
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, safe_width, help_y, help_x)
else:
help_win.erase()
help_win.refresh()
help_win.resize(help_height, safe_width)
try:
help_win.mvwin(help_y, help_x)
except curses.error:
# If moving fails due to edge conditions, pin to (0,0) as a fallback
help_y = 0
help_x = 0
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
help_win.border()
for idx, line_segments in enumerate(wrapped_help):
x_pos = 2 # Start after border
for text, color, bold, underline in line_segments:
try:
attr = get_color(color, bold=bold, underline=underline)
help_win.addstr(1 + idx, x_pos, text, attr)
x_pos += len(text)
except curses.error:
pass # Prevent crashes
help_win.refresh()
return help_win
def get_wrapped_help_text(
help_text: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], width: int, max_lines: int
) -> List[WrappedLine]:
"""Fetches and formats help text for display, ensuring it fits within the allowed lines."""
full_help_key = ".".join(transformed_path + [selected_option]) if selected_option else None
help_content = help_text.get(full_help_key, t("ui.help.no_help", default="No help available."))
wrap_width = max(width - 6, 10) # Ensure a valid wrapping width
# Color replacements
color_mappings = {
r"\[warning\](.*?)\[/warning\]": ("settings_warning", True, False), # Red for warnings
r"\[note\](.*?)\[/note\]": ("settings_note", True, False), # Green for notes
r"\[underline\](.*?)\[/underline\]": ("settings_default", False, True), # Underline
r"\\033\[31m(.*?)\\033\[0m": ("settings_warning", True, False), # Red text
r"\\033\[32m(.*?)\\033\[0m": ("settings_note", True, False), # Green text
r"\\033\[4m(.*?)\\033\[0m": ("settings_default", False, True), # Underline
}
def extract_ansi_segments(text: str) -> List[Segment]:
"""Extracts and replaces ANSI color codes, ensuring spaces are preserved."""
matches = []
last_pos = 0
pattern_matches = []
# Find all matches and store their positions
for pattern, (color, bold, underline) in color_mappings.items():
for match in re.finditer(pattern, text):
pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline))
# Sort matches by start position to process sequentially
pattern_matches.sort(key=lambda x: x[0])
for start, end, content, color, bold, underline in pattern_matches:
# Preserve non-matching text including spaces
if last_pos < start:
segment = text[last_pos:start]
matches.append((segment, "settings_default", False, False))
# Append the colored segment
matches.append((content, color, bold, underline))
last_pos = end
# Preserve any trailing text
if last_pos < len(text):
matches.append((text[last_pos:], "settings_default", False, False))
return matches
def wrap_ansi_text(segments: List[Segment], wrap_width: int) -> List[WrappedLine]:
"""Wraps text while preserving ANSI formatting and spaces."""
wrapped_lines = []
line_buffer = []
line_length = 0
for text, color, bold, underline in segments:
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
for word in words:
word_length = len(word)
if line_length + word_length > wrap_width and word.strip():
# If the word (ignoring spaces) exceeds width, wrap the line
wrapped_lines.append(line_buffer)
line_buffer = []
line_length = 0
line_buffer.append((word, color, bold, underline))
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer)
return wrapped_lines
raw_lines = help_content.split("\\n") # Preserve new lines
wrapped_help = []
for raw_line in raw_lines:
color_segments = extract_ansi_segments(raw_line)
wrapped_segments = wrap_ansi_text(color_segments, wrap_width)
wrapped_help.extend(wrapped_segments)
pass
# Trim and add ellipsis if needed
if len(wrapped_help) > max_lines:
wrapped_help = wrapped_help[:max_lines]
wrapped_help[-1].append(("...", "settings_default", False, False))
return wrapped_help
def text_width(text: str) -> int:
width = wcswidth(text)
if width >= 0:
return width
return sum(max(wcwidth(char), 0) for char in text)
def _is_regional_indicator(char: str) -> bool:
codepoint = ord(char)
return 0x1F1E6 <= codepoint <= 0x1F1FF
def _is_emoji_modifier(char: str) -> bool:
codepoint = ord(char)
return 0x1F3FB <= codepoint <= 0x1F3FF
def _is_display_modifier(char: str) -> bool:
return unicodedata.category(char) in {"Mn", "Mc", "Me"} or _is_emoji_modifier(char)
def iter_display_units(text: str) -> List[str]:
"""Split text into display units so emoji sequences stay intact."""
units: List[str] = []
index = 0
while index < len(text):
unit = text[index]
index += 1
if _is_regional_indicator(unit) and index < len(text) and _is_regional_indicator(text[index]):
unit += text[index]
index += 1
while index < len(text) and _is_display_modifier(text[index]):
unit += text[index]
index += 1
while index < len(text) and text[index] == ZWJ and index + 1 < len(text):
unit += text[index]
index += 1
unit += text[index]
index += 1
while index < len(text) and _is_display_modifier(text[index]):
unit += text[index]
index += 1
units.append(unit)
return units
def slice_text_to_width(text: str, max_width: int) -> str:
"""Return the longest prefix that fits within max_width terminal cells."""
if max_width <= 0:
return ""
chunk = ""
for unit in iter_display_units(text):
candidate = chunk + unit
if text_width(candidate) > max_width:
break
chunk = candidate
return chunk
def fit_text(text: str, width: int, suffix: str = "") -> str:
"""Trim and pad text so its terminal display width fits exactly."""
if width <= 0:
return ""
if text_width(text) > width:
suffix = slice_text_to_width(suffix, width)
available = max(0, width - text_width(suffix))
text = slice_text_to_width(text, available).rstrip() + suffix
padding = max(0, width - text_width(text))
return text + (" " * padding)
def split_text_to_width(text: str, max_width: int) -> List[str]:
"""Split text into chunks that each fit within max_width terminal cells."""
if max_width <= 0:
return [""]
chunks: List[str] = []
chunk = ""
for unit in iter_display_units(text):
candidate = chunk + unit
if chunk and text_width(candidate) > max_width:
chunks.append(chunk)
chunk = unit
else:
chunk = candidate
if text_width(chunk) > max_width:
chunks.append(slice_text_to_width(chunk, max_width))
chunk = ""
if chunk:
chunks.append(chunk)
return chunks or [""]
def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words."""
whitespace = "\t\n\x0b\x0c\r "
whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(" "))
text = text.translate(whitespace_trans)
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
wrapped_lines = []
line_buffer = ""
line_length = 0
margin = 2 # Left and right margin
wrap_width -= margin
for word in words:
word_chunks = split_text_to_width(word, wrap_width) if text_width(word) > wrap_width else [word]
for chunk in word_chunks:
chunk_length = text_width(chunk)
if line_length + chunk_length > wrap_width and chunk.strip():
wrapped_lines.append(line_buffer.strip())
line_buffer = ""
line_length = 0
line_buffer += chunk
line_length += chunk_length
if line_buffer:
wrapped_lines.append(line_buffer.strip())
return wrapped_lines
def move_main_highlight(
old_idx: int, new_idx, options: List[str], menu_win: curses.window, menu_pad: curses.window, ui_state: object
) -> None:
if old_idx == new_idx: # No-op
return
max_index = len(options) - 1
visible_height = menu_win.getmaxyx()[0] - 2
if new_idx < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = new_idx
elif new_idx >= ui_state.start_index[ui_state.current_window] + visible_height: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = new_idx - visible_height + 1
# Ensure start_index is within bounds
ui_state.start_index[ui_state.current_window] = max(
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
)
highlight_line(menu_win, menu_pad, old_idx, new_idx, visible_height)
if ui_state.current_window == 0: # hack to fix max_index
max_index += 1
draw_main_arrows(menu_win, max_index, window=ui_state.current_window)
menu_win.refresh()
def highlight_line(
menu_win: curses.window, menu_pad: curses.window, old_idx: int, new_idx: int, visible_height: int
) -> None:
if ui_state.current_window == 0:
color_old = (
get_color("channel_selected") if old_idx == ui_state.selected_channel else get_color("channel_list")
)
color_new = get_color("channel_list", reverse=True) if True else get_color("channel_list", reverse=True)
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, color_old)
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
elif ui_state.current_window == 2:
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(
ui_state.start_index[ui_state.current_window],
0,
menu_win.getbegyx()[0] + 1,
menu_win.getbegyx()[1] + 1,
menu_win.getbegyx()[0] + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 3,
)
def draw_main_arrows(win: object, max_index: int, window: int, **kwargs) -> None:
height, width = win.getmaxyx()
usable_height = height - 2
usable_width = width - 2
if window == 1 and ui_state.display_log:
if log_height := kwargs.get("log_height"):
usable_height -= log_height - 1
if usable_height < max_index:
if ui_state.start_index[window] > 0:
win.addstr(1, usable_width, "", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
if max_index - ui_state.start_index[window] - 1 >= usable_height:
win.addstr(usable_height, usable_width, "", get_color("settings_default"))
else:
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
def get_msg_window_lines(messages_win, packetlog_win) -> None:
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0
return messages_win.getmaxyx()[0] - 2 - packetlog_height

View File

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

View File

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

View File

@@ -1,143 +1,44 @@
import os
import json
import curses
from typing import Any, List, Dict, Optional
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
import contact.ui.default_config as config
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.ini_utils import parse_ini_file
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.singleton import menu_state
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
max_help_lines = 6
width = 80
save_option = "Save Changes"
translation_file = config.get_localisation_file(config.language)
field_mapping, help_text = parse_ini_file(translation_file)
translation_language = config.language
sensitive_settings = []
def edit_color_pair(key, current_value):
def reload_translations(language: Optional[str] = None) -> None:
global translation_file, field_mapping, help_text, translation_language
target_language = language or config.language
translation_file = config.get_localisation_file(target_language)
field_mapping, help_text = parse_ini_file(translation_file)
translation_language = target_language
def get_app_settings_key(menu_path: List[str], selected_key: str) -> str:
parts = ["app_settings"]
for part in menu_path:
if part in ("Main Menu", "App Settings"):
continue
parts.append(part)
parts.append(selected_key)
return ".".join(parts)
def get_app_settings_path_parts(menu_path: List[str]) -> List[str]:
parts = ["app_settings"]
for part in menu_path:
if part in ("Main Menu", "App Settings"):
continue
parts.append(part)
return parts
def lookup_app_settings_label(full_key: str, fallback: str) -> str:
label = field_mapping.get(full_key)
if label:
return label
parts = full_key.split(".")
if len(parts) >= 2 and parts[1].startswith("COLOR_CONFIG_"):
unified_key = ".".join([parts[0], "color_config"] + parts[2:])
return field_mapping.get(unified_key, fallback)
return fallback
def get_app_settings_help_path_parts(menu_path: List[str]) -> List[str]:
parts = get_app_settings_path_parts(menu_path)
if parts and parts[-1] in ("COLOR_CONFIG_DARK", "COLOR_CONFIG_LIGHT", "COLOR_CONFIG_GREEN"):
parts[-1] = "color_config"
return parts
def get_app_settings_header(menu_path: List[str]) -> str:
if not menu_path:
return ""
translated_parts = []
for idx, part in enumerate(menu_path):
if idx == 0:
translated_parts.append(field_mapping.get(part, part))
continue
if part in ("Main Menu", "App Settings"):
continue
full_key = ".".join(get_app_settings_path_parts(menu_path[: idx + 1]))
translated_parts.append(lookup_app_settings_label(full_key, part))
return " > ".join(translated_parts)
# 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, display_label: str, current_value: List[str]) -> List[str]:
"""
Allows the user to select a foreground and background color for a key.
"""
color_list = [" "] + list(COLOR_MAP.keys())
fg_color = get_list_input(
t(
"ui.prompt.select_foreground_color",
default="Select Foreground Color for {label}",
label=display_label,
),
current_value[0],
color_list,
)
bg_color = get_list_input(
t(
"ui.prompt.select_background_color",
default="Select Background Color for {label}",
label=display_label,
),
current_value[1],
color_list,
)
fg_color = get_list_input(f"Select Foreground Color for {key}", current_value[0], color_list)
bg_color = get_list_input(f"Select Background Color for {key}", current_value[1], color_list)
return [fg_color, bg_color]
def edit_value(key, current_value, menu_state):
def edit_value(key: str, display_label: 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()
# Display instructions
edit_win.addstr(
1,
2,
t("ui.label.editing", default="Editing {label}", label=display_label),
get_color("settings_default", bold=True),
)
edit_win.addstr(3, 2, t("ui.label.current_value", default="Current Value:"), get_color("settings_default"))
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
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)]
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
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
@@ -147,118 +48,58 @@ def edit_value(key: str, display_label: 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")
]
return get_list_input(
t("ui.prompt.select_value", default="Select {label}", label=display_label),
current_value,
theme_options,
)
elif key == "language":
language_options = config.get_localisation_options()
if not language_options:
return current_value
return get_list_input(
t("ui.prompt.select_value", default="Select {label}", label=display_label),
current_value,
language_options,
)
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(display_label, current_value, sort_options)
elif key == "notification_sound":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
sort_options = ['lastHeard', 'name', 'hops']
return get_list_input("Sort By", current_value, sort_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1)
scroll_offset = 0 # Determines which part of the text is visible
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,
t("ui.label.editing", default="Editing {label}", label=display_label),
get_color("settings_default", bold=True),
)
edit_win.addstr(
3, 2, t("ui.label.current_value", default="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, t("ui.label.new_value", default="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):
"""
Render the configuration menu with a Save button directly added to the window.
"""
if translation_language != config.language:
reload_translations()
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
# Determine menu items based on the type of current_menu
@@ -270,16 +111,14 @@ def display_menu() -> tuple[Any, Any, List[str]]:
options = [] # Fallback in case of unexpected data types
# Calculate dynamic dimensions for the menu
min_help_window_height = 6
max_menu_height = curses.LINES
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
menu_height = min(max_menu_height, num_items + 5)
num_items = len(options)
w = get_effective_width()
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
start_x = max(0, (curses.COLS - w) // 2)
start_y = (curses.LINES - menu_height) // 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"))
@@ -287,110 +126,114 @@ 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 = get_app_settings_header(menu_state.menu_path)
if len(header) > w - 4:
header = header[: w - 7] + "..."
header = " > ".join(menu_state.menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options
for idx, key in enumerate(options):
value = (
menu_state.current_menu[key]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))]
value = menu_state.current_menu[key] if isinstance(menu_state.current_menu, dict) else menu_state.current_menu[int(key.strip("[]"))]
display_key = f"{key}"[:width // 2 - 2]
display_value = (
f"{value}"[:width // 2 - 8]
)
if isinstance(menu_state.current_menu, dict):
full_key = get_app_settings_key(menu_state.menu_path, key)
display_key = lookup_app_settings_label(full_key, key)
else:
display_key = key
display_key = f"{display_key}"[: w // 2 - 2]
display_value = f"{value}"[: w // 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
save_label = t("ui.save_changes", default=save_option)
menu_win.addstr(
save_position,
(w - len(save_label)) // 2,
save_label,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))))
menu_win.refresh()
menu_pad.refresh(
menu_state.start_index[-1],
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
)
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
# Draw help window below the menu
global max_help_lines
remaining_space = curses.LINES - (start_y + menu_height + 2)
max_help_lines = max(remaining_space, 1)
transformed_path = get_app_settings_help_path_parts(menu_state.menu_path)
selected_option = (
options[min(menu_state.selected_index, len(options) - 1)] if options and menu_state.selected_index >= 0 else None
)
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
menu_state.help_win = update_help_window(
menu_state.help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
w,
help_y,
menu_win.getbegyx()[1],
)
draw_arrows(menu_win, visible_height, max_index, menu_state)
return menu_win, menu_pad, options
def update_app_settings_help(menu_win: curses.window, options: List[str]) -> None:
transformed_path = get_app_settings_help_path_parts(menu_state.menu_path)
selected_option = options[menu_state.selected_index] if menu_state.selected_index < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
menu_state.help_win = update_help_window(
menu_state.help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
menu_win.getmaxyx()[1],
help_y,
menu_win.getbegyx()[1],
)
def move_highlight(old_idx, new_idx, options, menu_win, menu_pad, menu_state):
if old_idx == new_idx: # No-op
return
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
# Adjust menu_state.start_index only when moving out of visible range
if new_idx == max_index and menu_state.show_save_option:
pass
elif new_idx < menu_state.start_index[-1]: # Moving above the visible area
menu_state.start_index[-1] = new_idx
elif new_idx >= menu_state.start_index[-1] + visible_height: # Moving below the visible area
menu_state.start_index[-1] = new_idx - visible_height
pass
# Ensure menu_state.start_index is within bounds
menu_state.start_index[-1] = max(0, min(menu_state.start_index[-1], max_index - visible_height + 1))
# Clear old selection
if menu_state.show_save_option and old_idx == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else:
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
# Highlight new selection
if menu_state.show_save_option and new_idx == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True))
else:
menu_pad.chgat(new_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(menu_state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
draw_arrows(menu_win, visible_height, max_index, menu_state)
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
def draw_arrows(win, visible_height, max_index, menu_state):
mi = max_index - (2 if menu_state.show_save_option else 0)
if visible_height < mi:
if menu_state.start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - menu_state.start_index[-1] >= visible_height + (0 if menu_state.show_save_option else 1) :
win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
def json_editor(stdscr, menu_state):
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))
file_path = os.path.join(parent_dir, "config.json")
menu_state.show_save_option = True # Always show the Save button
menu_state.help_win = None
menu_state.help_text = {}
# Ensure the file exists
if not os.path.exists(file_path):
@@ -398,70 +241,54 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
json.dump({}, f)
# Load JSON data
with open(file_path, "r", encoding="utf-8") as f:
with open(file_path, "r") as f:
original_data = json.load(f)
data = original_data # Reference to the original data
menu_state.current_menu = data # Track the current level of the menu
# Render the menu
menu_win, menu_pad, options = display_menu()
update_app_settings_help(menu_win, options)
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()
update_app_settings_help(menu_win, options)
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:
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
menu_state.help_win = move_highlight(
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
update_app_settings_help(menu_win, options)
move_highlight(old_selected_index, menu_state.selected_index, options, menu_win, menu_pad, menu_state)
elif key == curses.KEY_DOWN:
old_selected_index = menu_state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
menu_state.help_win = move_highlight(
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
update_app_settings_help(menu_win, options)
move_highlight(old_selected_index, menu_state.selected_index, options, menu_win, menu_pad, menu_state)
elif key == ord("\t") and menu_state.show_save_option:
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index
menu_state.help_win = move_highlight(
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
update_app_settings_help(menu_win, options)
move_highlight(old_selected_index, menu_state.selected_index, options, menu_win, menu_pad, menu_state)
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()
if menu_state.help_win:
menu_state.help_win.erase()
menu_state.help_win.refresh()
if menu_state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[menu_state.selected_index]
menu_state.menu_path.append(str(selected_key))
menu_state.start_index.append(0)
menu_state.menu_index.append(menu_state.selected_index)
# Handle nested data
if isinstance(menu_state.current_menu, dict):
if selected_key in menu_state.current_menu:
@@ -471,26 +298,13 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
elif isinstance(menu_state.current_menu, list):
selected_data = menu_state.current_menu[int(selected_key.strip("[]"))]
display_label = selected_key
if isinstance(menu_state.current_menu, dict):
path_for_label = (
menu_state.menu_path[:-1]
if menu_state.menu_path and menu_state.menu_path[-1] == str(selected_key)
else menu_state.menu_path
)
full_key = get_app_settings_key(path_for_label, selected_key)
display_label = lookup_app_settings_label(full_key, selected_key)
if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair
old = selected_data
new_value = edit_color_pair(selected_key, display_label, 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
@@ -499,32 +313,24 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
else:
# General value editing
old = selected_data
new_value = edit_value(selected_key, display_label, selected_data)
new_value = edit_value(selected_key, selected_data, menu_state)
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()
if menu_state.help_win:
menu_state.help_win.erase()
menu_state.help_win.refresh()
# menu_state.selected_index = menu_state.menu_index[-1]
@@ -535,44 +341,26 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
menu_state.current_menu = data
for path in menu_state.menu_path[2:]:
menu_state.current_menu = (
menu_state.current_menu[path]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(path.strip("[]"))]
)
menu_state.current_menu = menu_state.current_menu[path] if isinstance(menu_state.current_menu, dict) else menu_state.current_menu[int(path.strip("[]"))]
else:
# Exit the editor
if made_changes:
save_prompt = get_list_input(
t("ui.confirm.save_before_exit", default="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()
break
def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
def save_json(file_path, 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)
reload_translations(data.get("language"))
def main(stdscr: curses.window) -> None:
def main(stdscr):
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
@@ -581,6 +369,5 @@ def main(stdscr: curses.window) -> None:
setup_colors()
json_editor(stdscr, menu_state)
if __name__ == "__main__":
curses.wrapper(main)
curses.wrapper(main)

View File

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

View File

@@ -1,25 +1,13 @@
import yaml
import logging
import time
from typing import List
from google.protobuf.json_format import MessageToDict
from meshtastic import mt_config
from meshtastic import BROADCAST_ADDR, mt_config
from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
# defs are from meshtastic/python/main
def _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0+ exposes `is_repeated`, while older versions require
checking `label == LABEL_REPEATED`.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def traverseConfig(config_root, config, interface_config) -> bool:
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
snake_name = camel_to_snake(config_root)
@@ -32,7 +20,6 @@ def traverseConfig(config_root, config, interface_config) -> bool:
return True
def splitCompoundName(comp_name: str) -> List[str]:
"""Split compound (dot separated) preference name into parts"""
name: List[str] = comp_name.split(".")
@@ -41,7 +28,6 @@ def splitCompoundName(comp_name: str) -> List[str]:
name.append(comp_name)
return name
def setPref(config, comp_name, raw_val) -> bool:
"""Set a channel or preferences value"""
@@ -89,7 +75,9 @@ def setPref(config, comp_name, raw_val) -> bool:
if e:
val = e.number
else:
logging.info(f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it.")
logging.info(
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
)
logging.info(f"Choices in sorted order are:")
names = []
for f in enumType.values:
@@ -100,7 +88,7 @@ def setPref(config, comp_name, raw_val) -> bool:
return False
# repeating fields need to be handled with append, not setattr
if not _is_repeated_field(pref):
if pref.label != pref.LABEL_REPEATED:
try:
if config_type.message_type is not None:
config_values = getattr(config_part, config_type.name)
@@ -134,46 +122,50 @@ def setPref(config, comp_name, raw_val) -> bool:
return True
def config_import(interface, filename):
def config_import(node_state, filename):
with open(filename, encoding="utf8") as file:
configuration = yaml.safe_load(file)
closeNow = True
interface.getNode("^local", False).beginSettingsTransaction()
node_state.interface.getNode('^local', False).beginSettingsTransaction()
if "owner" in configuration:
logging.info(f"Setting device owner to {configuration['owner']}")
waitForAckNak = True
interface.getNode("^local", False).setOwner(configuration["owner"])
time.sleep(0.5)
node_state.interface.getNode('^local', False).setOwner(configuration["owner"])
if "owner_short" in configuration:
logging.info(f"Setting device owner short to {configuration['owner_short']}")
logging.info(
f"Setting device owner short to {configuration['owner_short']}"
)
waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
time.sleep(0.5)
node_state.interface.getNode('^local', False).setOwner(
long_name=None, short_name=configuration["owner_short"]
)
if "ownerShort" in configuration:
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
logging.info(
f"Setting device owner short to {configuration['ownerShort']}"
)
waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
time.sleep(0.5)
node_state.interface.getNode('^local', False).setOwner(
long_name=None, short_name=configuration["ownerShort"]
)
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)
node_state.interface.getNode('^local').setURL(configuration["channel_url"])
if "channelUrl" in configuration:
logging.info(f"Setting channel url to {configuration['channelUrl']}")
interface.getNode("^local").setURL(configuration["channelUrl"])
time.sleep(0.5)
node_state.interface.getNode('^local').setURL(configuration["channelUrl"])
if "location" in configuration:
alt = 0
lat = 0.0
lon = 0.0
localConfig = interface.localNode.localConfig
localConfig = node_state.interface.localNode.localConfig
if "alt" in configuration["location"]:
alt = int(configuration["location"]["alt"] or 0)
@@ -185,39 +177,43 @@ def config_import(interface, filename):
lon = float(configuration["location"]["lon"] or 0)
logging.info(f"Fixing longitude at {lon} degrees")
logging.info("Setting device position")
interface.localNode.setFixedPosition(lat, lon, alt)
time.sleep(0.5)
node_state.interface.localNode.setFixedPosition(lat, lon, alt)
if "config" in configuration:
localConfig = interface.getNode("^local").localConfig
localConfig = node_state.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)
traverseConfig(
section, configuration["config"][section], localConfig
)
node_state.interface.getNode('^local').writeConfig(
camel_to_snake(section)
)
if "module_config" in configuration:
moduleConfig = interface.getNode("^local").moduleConfig
moduleConfig = node_state.interface.getNode('^local').moduleConfig
for section in configuration["module_config"]:
traverseConfig(
section,
configuration["module_config"][section],
moduleConfig,
)
interface.getNode("^local").writeConfig(camel_to_snake(section))
time.sleep(0.5)
node_state.interface.getNode('^local').writeConfig(
camel_to_snake(section)
)
interface.getNode("^local", False).commitSettingsTransaction()
node_state.interface.getNode('^local', False).commitSettingsTransaction()
logging.info("Writing modified configuration to device")
def config_export(interface) -> str:
def config_export(node_state) -> str:
"""used in --export-config"""
configObj = {}
owner = interface.getLongName()
owner_short = interface.getShortName()
channel_url = interface.localNode.getURL()
myinfo = interface.getMyNodeInfo()
owner = node_state.interface.getLongName()
owner_short = node_state.interface.getShortName()
channel_url = node_state.interface.localNode.getURL()
myinfo = node_state.interface.getMyNodeInfo()
pos = myinfo.get("position")
lat = None
lon = None
@@ -242,7 +238,7 @@ def config_export(interface) -> str:
if alt:
configObj["location"]["alt"] = alt
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
config = MessageToDict(node_state.interface.localNode.localConfig) #checkme - Used as a dictionary here and a string below
if config:
# Convert inner keys to correct snake/camelCase
prefs = {}
@@ -253,19 +249,19 @@ def config_export(interface) -> str:
prefs[pref] = config[pref]
# mark base64 encoded fields as such
if pref == "security":
if "privateKey" in prefs[pref]:
prefs[pref]["privateKey"] = "base64:" + prefs[pref]["privateKey"]
if "publicKey" in prefs[pref]:
prefs[pref]["publicKey"] = "base64:" + prefs[pref]["publicKey"]
if "adminKey" in prefs[pref]:
for i in range(len(prefs[pref]["adminKey"])):
prefs[pref]["adminKey"][i] = "base64:" + prefs[pref]["adminKey"][i]
if 'privateKey' in prefs[pref]:
prefs[pref]['privateKey'] = 'base64:' + prefs[pref]['privateKey']
if 'publicKey' in prefs[pref]:
prefs[pref]['publicKey'] = 'base64:' + prefs[pref]['publicKey']
if 'adminKey' in prefs[pref]:
for i in range(len(prefs[pref]['adminKey'])):
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
if mt_config.camel_case:
configObj["config"] = config # Identical command here and 2 lines below?
configObj["config"] = config #Identical command here and 2 lines below?
else:
configObj["config"] = config
module_config = MessageToDict(interface.localNode.moduleConfig)
module_config = MessageToDict(node_state.interface.localNode.moduleConfig)
if module_config:
# Convert inner keys to correct snake/camelCase
prefs = {}
@@ -277,9 +273,9 @@ def config_export(interface) -> str:
else:
configObj["module_config"] = prefs
config_txt = "# start of Meshtastic configure yaml\n" # checkme - "config" (now changed to config_out)
# was used as a string here and a Dictionary above
config_txt = "# start of Meshtastic configure yaml\n" #checkme - "config" (now changed to config_out)
#was used as a string here and a Dictionary above
config_txt += yaml.dump(configObj)
# logging.info(config_txt)
return config_txt
return config_txt

View File

@@ -1,18 +1,65 @@
from typing import List
import re
def transform_menu_path(menu_path: List[str]) -> List[str]:
"""Applies path replacements and normalizes entries in the menu path."""
path_replacements = {"Radio Settings": "config", "Module Settings": "module"}
def parse_ini_file(ini_file_path):
field_mapping = {}
help_text = {}
current_section = None
transformed_path: List[str] = []
with open(ini_file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(';') or line.startswith('#'):
continue
# Handle sections like [config.device]
if line.startswith('[') and line.endswith(']'):
current_section = line[1:-1]
continue
# Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(',', 2)]
if len(parts) >= 2:
key = parts[0]
# If key is 'title', map directly to the section
if key == 'title':
full_key = current_section
else:
full_key = f"{current_section}.{key}" if current_section else key
# Use the provided human-readable name or fallback to key
human_readable_name = parts[1] if parts[1] else key
field_mapping[full_key] = human_readable_name
# Handle help text or default
help = parts[2] if len(parts) == 3 and parts[2] else "No help available."
help_text[full_key] = help
else:
# Handle cases with only the key present
full_key = f"{current_section}.{key}" if current_section else key
field_mapping[full_key] = key
help_text[full_key] = "No help available."
return field_mapping, help_text
def transform_menu_path(menu_path):
"""Applies path replacements and normalizes entries in the menu path."""
path_replacements = {
"Radio Settings": "config",
"Module Settings": "module"
}
transformed_path = []
for part in menu_path[1:]: # Skip 'Main Menu'
# Apply fixed replacements
part = path_replacements.get(part, part)
# Normalize entries like "Channel 1", "Channel 2", etc.
if re.match(r"Channel\s+\d+", part, re.IGNORECASE):
if re.match(r'Channel\s+\d+', part, re.IGNORECASE):
part = "channel"
transformed_path.append(part)

View File

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

View File

@@ -1,31 +0,0 @@
from typing import Optional
import contact.ui.default_config as config
from contact.utilities.ini_utils import parse_ini_file
_translations = {}
_language = None
def _load_translations() -> None:
global _translations, _language
language = config.language
if _translations and _language == language:
return
translation_file = config.get_localisation_file(language)
_translations, _ = parse_ini_file(translation_file)
_language = language
def t(key: str, default: Optional[str] = None, **kwargs: object) -> str:
_load_translations()
text = _translations.get(key, default if default is not None else key)
try:
return text.format(**kwargs)
except Exception:
return text
def t_text(text: str, **kwargs: object) -> str:
return t(text, default=text, **kwargs)

View File

@@ -1,54 +0,0 @@
from typing import Optional, Tuple, Dict
from contact.utilities import i18n
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Parses an INI file and returns a mapping of keys to human-readable names and help text."""
try:
default_help = i18n.t("ui.help.no_help", default="No help available.")
except Exception:
default_help = "No help available."
field_mapping: Dict[str, str] = {}
help_text: Dict[str, str] = {}
current_section: Optional[str] = None
with open(ini_file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(";") or line.startswith("#"):
continue
# Handle sections like [config.device]
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
continue
# Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(",", 2)]
if len(parts) >= 2:
key = parts[0]
# If key is 'title', map directly to the section
if key == "title":
full_key = current_section
else:
full_key = f"{current_section}.{key}" if current_section else key
# Use the provided human-readable name or fallback to key
human_readable_name = parts[1] if parts[1] else key
field_mapping[full_key] = human_readable_name
# Handle help text or default
help = parts[2] if len(parts) == 3 and parts[2] else default_help
help_text[full_key] = help
else:
# Handle cases with only the key present
full_key = f"{current_section}.{key}" if current_section else key
field_mapping[full_key] = key
help_text[full_key] = default_help
return field_mapping, help_text

View File

@@ -2,250 +2,114 @@ import base64
import binascii
import curses
import ipaddress
from typing import Any, Optional, List
import re
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.i18n import t, t_text
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 wrap_text(text, wrap_width):
"""Wraps text while preserving spaces and breaking long words."""
words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately
wrapped_lines = []
line_buffer = ""
line_length = 0
margin = 2 # Left and right margin
wrap_width -= margin
for word in words:
word_length = len(word)
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
if word_length > wrap_width: # Break long words
if line_buffer:
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
for i in range(0, word_length, wrap_width):
wrapped_lines.append(word[i:i+wrap_width])
continue
if line_length + word_length > wrap_width and word.strip():
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
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(t("ui.dialog.invalid_input", default="Invalid Input"), t_text(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)
line_buffer += word
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer)
def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]:
return wrapped_lines
def get_text_input(prompt):
"""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()
prompt = t_text(prompt)
# 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
if row >= height - 3: # Prevent overflow
break
prompt_text = t("ui.prompt.enter_new_value", default="Enter new value: ")
prompt_text = "Enter new value: "
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
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,
t("ui.error.value_empty", default="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,
t("ui.error.value_exact_length", default="Value must be exactly {length} characters long.", length=min_length),
redraw_func=redraw_input_win,
)
continue
else:
if length < min_length:
invalid_input(
input_win,
t("ui.error.value_min_length", default="Value must be at least {length} characters long.", length=min_length),
redraw_func=redraw_input_win,
)
continue
if max_length is not None and length > max_length:
invalid_input(
input_win,
t("ui.error.value_max_length", default="Value must be no more than {length} characters long.", length=max_length),
redraw_func=redraw_input_win,
)
continue
if input_type is int:
if not user_input.isdigit():
invalid_input(
input_win,
t("ui.error.digits_only", default="Only numeric digits (0-9) allowed."),
redraw_func=redraw_input_win,
)
continue
int_val = int(user_input)
if not (min_value <= int_val <= max_value):
invalid_input(
input_win,
t(
"ui.error.number_range",
default="Enter a number between {min_value} and {max_value}.",
min_value=min_value,
max_value=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,
t(
"ui.error.number_range",
default="Enter a number between {min_value} and {max_value}.",
min_value=min_value,
max_value=max_value,
),
redraw_func=redraw_input_win,
)
continue
except ValueError:
invalid_input(
input_win,
t("ui.error.float_invalid", default="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
remaining_text = user_input[first_line_width:] # Remaining text for wrapping
wrapped_lines = wrap_text(remaining_text, wrap_width=input_width) if remaining_text else []
# 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"))
@@ -261,37 +125,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 get_admin_key_input(current_value):
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)
@@ -299,59 +158,44 @@ 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,
t("ui.prompt.edit_admin_keys", default="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(
3 + i,
2,
f"{prefix}{t('ui.label.admin_key', default='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, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
repeated_win.addstr(3 + i, 18, line) # Align text for easier editing
# Move cursor to the correct position inside the field
curses.curs_set(1)
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, t_text(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
elif key == ord('\n'): # Enter key to save and return
if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes
curses.noecho()
curses.curs_set(0)
return user_values # Return the edited Base64 values
else:
invalid_input = t(
"ui.error.admin_key_invalid",
default="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
@@ -362,282 +206,249 @@ 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]:
def get_repeated_input(current_value):
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 = ""
def redraw():
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(
1,
2,
t("ui.prompt.edit_values", default="Edit up to 3 Values:"),
get_color("settings_default", bold=True),
)
win_h, win_w = repeated_win.getmaxyx()
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " "
repeated_win.addstr(
3 + i,
2,
f"{prefix}{t('ui.label.value', default='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
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))
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos]))
repeated_win.refresh()
# Editable list of values (max 3 values)
user_values = current_value[:3]
cursor_pos = 0 # Track which value is being edited
error_message = ""
while True:
if menu_state.need_redraw:
menu_state.need_redraw = False
redraw()
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
redraw()
# Display current values, allowing editing
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " # Highlight the current line
repeated_win.addstr(3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
repeated_win.addstr(3 + i, 18, line)
try:
key = repeated_win.get_wch()
except curses.error:
continue # ignore timeout or input issues
# 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
if key in (27, curses.KEY_LEFT): # ESC or Left Arrow
# Show error message if needed
if error_message:
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
repeated_win.refresh()
key = repeated_win.getch()
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
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
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
from contact.utilities.singleton import menu_state # Ensure this is imported
def get_fixed32_input(current_value: int) -> int:
original_value = current_value
try:
ip_string = str(ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False)))
except Exception:
ip_string = str(ipaddress.IPv4Address(current_value))
def get_fixed32_input(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,
t("ui.prompt.enter_ip", default="Enter an IP address (xxx.xxx.xxx.xxx):"),
get_color("settings_default", bold=True),
)
fixed32_win.addstr(
3, 2, f"{t('ui.label.current', default='Current')}: {ip_string}", get_color("settings_default")
)
fixed32_win.addstr(
5,
2,
f"{t('ui.label.new_value', default='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.from_bytes(ipaddress.IPv4Address(user_input).packed, "little", signed=False)
fixed32_address = ipaddress.ip_address(user_input)
return int(fixed32_address) # Return the valid IP address
else:
fixed32_win.addstr(
7,
2,
t("ui.error.ip_invalid", default="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, curses.KEY_DC, 127, 8, "\b", "\x7f"):
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, current_option, list_options):
"""
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():
translated_prompt = t_text(prompt)
list_win.erase()
list_win.border()
list_win.addstr(1, 2, translated_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))
display_item = t_text(item)
list_pad.addstr(idx, 0, display_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)
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)
move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad)
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
move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad)
elif key == ord('\n'): # Enter key
list_win.clear()
list_win.refresh()
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
def move_highlight(old_idx, new_idx, options, list_win, list_pad):
global scroll_offset
if 'scroll_offset' not in globals():
scroll_offset = 0 # Initialize if not set
if old_idx == new_idx:
return # No-op
max_index = len(options) - 1
visible_height = list_win.getmaxyx()[0] - 5
# Adjust scroll_offset only when moving out of visible range
if new_idx < scroll_offset: # Moving above the visible area
scroll_offset = new_idx
elif new_idx >= scroll_offset + visible_height: # Moving below the visible area
scroll_offset = new_idx - visible_height
# Ensure scroll_offset is within bounds
scroll_offset = max(0, min(scroll_offset, max_index - visible_height + 1))
# Clear old highlight
list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default"))
# Highlight new selection
list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse=True))
list_win.refresh()
# Refresh pad only if scrolling is needed
list_pad.refresh(scroll_offset, 0,
list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4,
list_win.getbegyx()[0] + 3 + visible_height,
list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4)
draw_arrows(list_win, visible_height, max_index, scroll_offset)
return scroll_offset # Return updated scroll_offset to be stored externally
def draw_arrows(win, visible_height, max_index, start_index):
if visible_height < max_index:
if start_index > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if max_index - start_index > visible_height:
win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))

View File

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

View File

@@ -4,8 +4,7 @@ import logging
import base64
import time
def save_changes(interface, modified_settings, menu_state):
def save_changes(node_state, modified_settings, menu_state):
"""
Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance
@@ -16,16 +15,16 @@ def save_changes(interface, modified_settings, menu_state):
if not modified_settings:
logging.info("No changes to save. modified_settings is empty.")
return
node = interface.getNode("^local")
node = node_state.interface.getNode('^local')
admin_key_backup = None
if "admin_key" in modified_settings:
if 'admin_key' in modified_settings:
# Get reference to security config
security_config = node.localConfig.security
admin_keys = modified_settings["admin_key"]
admin_keys = modified_settings['admin_key']
# Filter out empty keys
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
valid_keys = [key for key in admin_keys if key and key.strip() and key != b'']
if not valid_keys:
logging.warning("No valid admin keys provided. Skipping admin key update.")
@@ -43,25 +42,25 @@ def save_changes(interface, modified_settings, menu_state):
security_config.admin_key.append(key)
node.writeConfig("security")
logging.info("Admin keys updated successfully!")
# Backup 'admin_key' before removing it
admin_key_backup = modified_settings.get("admin_key", None)
admin_key_backup = modified_settings.get('admin_key', None)
# Remove 'admin_key' from modified_settings to prevent interference
del modified_settings["admin_key"]
del modified_settings['admin_key']
# Return early if there are no other settings left to process
if not modified_settings:
return
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
config_category = menu_state.menu_path[2].lower() # for radio and module configs
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
config_category = menu_state.menu_path[2].lower() # for radio and module configs
if {"latitude", "longitude", "altitude"} & modified_settings.keys():
lat = float(modified_settings.get("latitude", 0.0))
lon = float(modified_settings.get("longitude", 0.0))
alt = int(modified_settings.get("altitude", 0))
if {'latitude', 'longitude', 'altitude'} & modified_settings.keys():
lat = float(modified_settings.get('latitude', 0.0))
lon = float(modified_settings.get('longitude', 0.0))
alt = int(modified_settings.get('altitude', 0))
interface.localNode.setFixedPosition(lat, lon, alt)
node_state.interface.localNode.setFixedPosition(lat, lon, alt)
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return
@@ -74,13 +73,11 @@ def save_changes(interface, modified_settings, menu_state):
node.setOwner(long_name, short_name, is_licensed)
logging.info(
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
)
logging.info(f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}")
return
elif menu_state.menu_path[1] == "Channels": # for channel configs
elif menu_state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels"
try:
@@ -91,9 +88,9 @@ def save_changes(interface, modified_settings, menu_state):
channel = node.channels[channel_num]
for key, value in modified_settings.items():
if key == "psk": # Special case: decode Base64 for psk
if key == 'psk': # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value)
elif key == "position_precision": # Special case: module_settings
elif key == 'position_precision': # Special case: module_settings
channel.settings.module_settings.position_precision = value
else:
setattr(channel.settings, key, value) # Use setattr for other fields
@@ -112,23 +109,16 @@ def save_changes(interface, modified_settings, menu_state):
else:
config_category = None
# Resolve the target config container, including nested sub-messages (e.g., network.ipv4_config)
config_container = None
if hasattr(node.localConfig, config_category):
config_container = getattr(node.localConfig, config_category)
elif hasattr(node.moduleConfig, config_category):
config_container = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
return
if len(menu_state.menu_path) >= 4:
nested_key = menu_state.menu_path[3]
if hasattr(config_container, nested_key):
config_container = getattr(config_container, nested_key)
for config_item, new_value in modified_settings.items():
config_subcategory = config_container
# Check if the category exists in localConfig
if hasattr(node.localConfig, config_category):
config_subcategory = getattr(node.localConfig, config_category)
# Check if the category exists in moduleConfig
elif hasattr(node.moduleConfig, config_category):
config_subcategory = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
continue
# Check if the config_item exists in the subcategory
if hasattr(config_subcategory, config_item):
@@ -145,9 +135,7 @@ def save_changes(interface, modified_settings, menu_state):
setattr(field, sub_field, sub_value)
logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}")
else:
logging.warning(
f"Sub-field '{sub_field}' not found in {config_category}.{config_item}"
)
logging.warning(f"Sub-field '{sub_field}' not found in {config_category}.{config_item}")
else:
logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.")
else:
@@ -163,9 +151,9 @@ def save_changes(interface, modified_settings, menu_state):
logging.info(f"Changes written to config category: {config_category}")
if admin_key_backup is not None:
modified_settings["admin_key"] = admin_key_backup
modified_settings['admin_key'] = admin_key_backup
except Exception as e:
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
except Exception as e:
logging.error(f"Error saving changes: {e}")
logging.error(f"Error saving changes: {e}")

View File

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

View File

@@ -1,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
# Python 3.9-compatible alternative to match/case.
if key == "uptime_seconds":
# convert seconds to hours, for our sanity
value = round(value / 60 / 60, 1)
elif key in ("longitude_i", "latitude_i"):
# 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
value = round(value * 1e-7, 6)
elif key == "wind_direction":
# Convert wind direction from degrees to abbreviation
value = humanize_wind_direction(value)
elif key == "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,22 +1,15 @@
import contact.globals as globals
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():
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
node = interface_state.interface.getNode("^local")
"""Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
node = globals.interface.getNode('^local')
device_channels = node.channels
# Clear and rebuild channel list
# ui_state.channel_list = []
# globals.channel_list = []
for device_channel in device_channels:
if device_channel.role:
@@ -27,75 +20,65 @@ def get_channels():
# If channel name is blank, use the modem preset
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[
modem_preset_enum
].name
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
channel_name = convert_to_camel_case(modem_preset_string)
# Add channel to ui_state.channel_list if not already present
if channel_name not in ui_state.channel_list:
ui_state.channel_list.append(channel_name)
# Add channel to globals.channel_list if not already present
if channel_name not in globals.channel_list:
globals.channel_list.append(channel_name)
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
if channel_name not in ui_state.all_messages:
ui_state.all_messages[channel_name] = []
# Initialize globals.all_messages[channel_name] if it doesn't exist
if channel_name not in globals.all_messages:
globals.all_messages[channel_name] = []
return ui_state.channel_list
return globals.channel_list
def get_node_list():
if interface_state.interface.nodes:
my_node_num = interface_state.myNodeNum
if globals.interface.nodes:
my_node_num = globals.myNodeNum
def node_sort(node):
if config.node_sort == "lastHeard":
return -node["lastHeard"] if ("lastHeard" in node and isinstance(node["lastHeard"], int)) else 0
elif config.node_sort == "name":
return node["user"]["longName"]
elif config.node_sort == "hops":
return node["hopsAway"] if "hopsAway" in node else 100
if(config.node_sort == 'lastHeard'):
return -node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0
elif(config.node_sort == "name"):
return node['user']['longName']
elif(config.node_sort == "hops"):
return node['hopsAway'] if 'hopsAway' in node else 100
else:
return node
sorted_nodes = sorted(interface_state.interface.nodes.values(), key=node_sort)
sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort)
# Move favorite nodes to the beginning
sorted_nodes = sorted(
sorted_nodes, key=lambda node: node["isFavorite"] if "isFavorite" in node else False, reverse=True
)
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isFavorite'] if 'isFavorite' in node else False, reverse = True)
# Move ignored nodes to the end
sorted_nodes = sorted(sorted_nodes, key=lambda node: node["isIgnored"] if "isIgnored" in node else False)
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isIgnored'] if 'isIgnored' in node else False)
node_list = [node["num"] for node in sorted_nodes if node["num"] != my_node_num]
node_list = [node['num'] for node in sorted_nodes if node['num'] != my_node_num]
return [my_node_num] + node_list # Ensuring your node is always first
return []
def refresh_node_list():
new_node_list = get_node_list()
if new_node_list != ui_state.node_list:
ui_state.node_list = new_node_list
if new_node_list != globals.node_list:
globals.node_list = new_node_list
return True
return False
def get_nodeNum():
myinfo = interface_state.interface.getMyNodeInfo()
myNodeNum = myinfo["num"]
return myNodeNum
def get_nodeNum(node_state):
myinfo = node_state.interface.getMyNodeInfo()
node_state.myNodeNum = myinfo['num']
def decimal_to_hex(decimal_number):
return f"!{decimal_number:08x}"
def convert_to_camel_case(string):
words = string.split("_")
camel_case_string = "".join(word.capitalize() for word in words)
words = string.split('_')
camel_case_string = ''.join(word.capitalize() for word in words)
return camel_case_string
def get_time_val_units(time_delta):
value = 0
unit = ""
@@ -123,13 +106,11 @@ def get_time_val_units(time_delta):
unit = "s"
return (value, unit)
def get_readable_duration(seconds):
delta = datetime.timedelta(seconds=seconds)
delta = datetime.timedelta(seconds = seconds)
val, units = get_time_val_units(delta)
return f"{val} {units}"
def get_time_ago(timestamp):
now = datetime.datetime.now()
dt = datetime.datetime.fromtimestamp(timestamp)
@@ -140,80 +121,3 @@ def get_time_ago(timestamp):
return f"{value} {unit} ago"
return "now"
def add_new_message(channel_id, prefix, message):
if channel_id not in ui_state.all_messages:
ui_state.all_messages[channel_id] = []
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
# Retrieve the last timestamp if available
channel_messages = ui_state.all_messages[channel_id]
if channel_messages:
# Check the last entry for a timestamp
for entry in reversed(channel_messages):
if entry[0].startswith("--"):
last_hour = entry[0].strip("- ").strip()
break
else:
last_hour = None
else:
last_hour = None
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
# Add the message
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,16 +1,15 @@
[project]
name = "contact"
version = "1.4.20"
version = "1.3.2"
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"}
]
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.9,<3.15"
requires-python = ">=3.9,<3.14"
dependencies = [
"meshtastic (>=2.7.5,<3.0.0)",
"wcwidth (>=0.2.13,<1.0.0)"
"meshtastic (>=2.6.0,<3.0.0)"
]
[project.urls]

View File

@@ -1,2 +1 @@
meshtastic
wcwidth>=0.2.13,<1.0.0