mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
62 Commits
close-inte
...
1.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c492c96685 | ||
|
|
90376d35f3 | ||
|
|
4b35a74e2c | ||
|
|
ecc5308dad | ||
|
|
8f376edabe | ||
|
|
e5ef87ec19 | ||
|
|
1b6d269d50 | ||
|
|
1d95dae536 | ||
|
|
705b25192c | ||
|
|
6c5ae3b168 | ||
|
|
02b4866a38 | ||
|
|
286b5a531b | ||
|
|
004868c7fc | ||
|
|
b43e3f4868 | ||
|
|
2e8e21f5ba | ||
|
|
09f0d626df | ||
|
|
8c37f2394b | ||
|
|
568f29ee29 | ||
|
|
87127adef1 | ||
|
|
dd0eb1473c | ||
|
|
7d9703a548 | ||
|
|
68b8d15181 | ||
|
|
4ef87871df | ||
|
|
18df7d326a | ||
|
|
c7b54caf45 | ||
|
|
773f43edd8 | ||
|
|
6af1c46bd3 | ||
|
|
7e3e44df24 | ||
|
|
45626f5e83 | ||
|
|
e9181972b2 | ||
|
|
795ab84ef5 | ||
|
|
5e108c5fe5 | ||
|
|
edef37b116 | ||
|
|
e7e1bf7852 | ||
|
|
1c2384ea8d | ||
|
|
4cda264746 | ||
|
|
0005aaf438 | ||
|
|
f39a09646a | ||
|
|
055aaeb633 | ||
|
|
edd86c1d4b | ||
|
|
df4ed16bae | ||
|
|
5d2529e679 | ||
|
|
a35a2c52fb | ||
|
|
26b8e3f1ba | ||
|
|
6527e7cf89 | ||
|
|
9452d74596 | ||
|
|
47f0e9d16f | ||
|
|
c42657844d | ||
|
|
7c5d1457ec | ||
|
|
4d0ea8fea3 | ||
|
|
34ea02920d | ||
|
|
173a7effe2 | ||
|
|
324b6721f7 | ||
|
|
cbc71a2b05 | ||
|
|
ff22527fe8 | ||
|
|
923f52a66b | ||
|
|
8fd48c5e5f | ||
|
|
f11f7bb9e0 | ||
|
|
ecd2d2d692 | ||
|
|
bdae90ecca | ||
|
|
56637f806b | ||
|
|
c6abedec75 |
47
.github/workflows/contact-buildx.yml
vendored
Normal file
47
.github/workflows/contact-buildx.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
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 }}
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
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" ]
|
||||
17
README.md
17
README.md
@@ -16,14 +16,27 @@ pip install contact
|
||||
|
||||
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">
|
||||
<img width="991" height="516" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/76722145-e8a4-4f01-8898-f4ae794b5d7b" />
|
||||
|
||||
<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`
|
||||
|
||||
<img width="696" alt="Screenshot 2025-04-08 at 6 10 06 PM" 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
|
||||
```
|
||||
|
||||
## Message Persistence
|
||||
|
||||
All messages will saved in a SQLite DB and restored upon relaunch of the app. You may delete `client.db` if you wish to erase all stored messages and node data. If multiple nodes are used, each will independently store data in the database, but the data will not be shared or viewable between nodes.
|
||||
|
||||
@@ -19,6 +19,7 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
# Third-party
|
||||
from pubsub import pub
|
||||
@@ -32,8 +33,11 @@ from contact.ui.contact_ui import main_ui
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
|
||||
from contact.utilities.demo_data import build_demo_interface, configure_demo_database, seed_demo_messages
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import initialize_interface
|
||||
from contact.utilities.i18n import t
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||
|
||||
@@ -57,18 +61,62 @@ app_state.lock = threading.Lock()
|
||||
# ------------------------------------------------------------------------------
|
||||
# Main Program Logic
|
||||
# ------------------------------------------------------------------------------
|
||||
def prompt_region_if_unset(args: object) -> None:
|
||||
def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None) -> 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)
|
||||
close_interface(interface_state.interface)
|
||||
if stdscr is not None:
|
||||
draw_splash(stdscr)
|
||||
interface_state.interface = reconnect_interface(args)
|
||||
|
||||
|
||||
def initialize_globals() -> None:
|
||||
def close_interface(interface: object) -> None:
|
||||
if interface is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
interface.close()
|
||||
|
||||
|
||||
def interface_is_ready(interface: object) -> bool:
|
||||
try:
|
||||
return getattr(interface, "localNode", None) is not None and interface.localNode.localConfig is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
|
||||
while True:
|
||||
interface = initialize_runtime_interface(args)
|
||||
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
|
||||
return interface
|
||||
|
||||
choice = get_list_input(
|
||||
t("ui.prompt.node_not_found", default="No node found. Retry connection?"),
|
||||
"Retry",
|
||||
["Retry", "Close"],
|
||||
mandatory=True,
|
||||
)
|
||||
close_interface(interface)
|
||||
if choice == "Close":
|
||||
return None
|
||||
|
||||
draw_splash(stdscr)
|
||||
|
||||
|
||||
def initialize_globals(seed_demo: bool = False) -> None:
|
||||
"""Initializes interface and shared globals."""
|
||||
|
||||
ui_state.channel_list = []
|
||||
ui_state.all_messages = {}
|
||||
ui_state.notifications = []
|
||||
ui_state.packet_buffer = []
|
||||
ui_state.node_list = []
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.selected_message = 0
|
||||
ui_state.selected_node = 0
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
interface_state.myNodeNum = get_nodeNum()
|
||||
ui_state.channel_list = get_channels()
|
||||
ui_state.node_list = get_node_list()
|
||||
@@ -76,15 +124,25 @@ def initialize_globals() -> None:
|
||||
pub.subscribe(on_receive, "meshtastic.receive")
|
||||
|
||||
init_nodedb()
|
||||
if seed_demo:
|
||||
seed_demo_messages()
|
||||
load_messages_from_db()
|
||||
|
||||
|
||||
def initialize_runtime_interface(args: object):
|
||||
if getattr(args, "demo_screenshot", False):
|
||||
configure_demo_database()
|
||||
return build_demo_interface()
|
||||
return initialize_interface(args)
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
"""Main entry point for the curses UI."""
|
||||
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
setup_colors()
|
||||
ensure_min_rows(stdscr)
|
||||
draw_splash(stdscr)
|
||||
|
||||
args = setup_parser().parse_args()
|
||||
@@ -95,12 +153,14 @@ def main(stdscr: curses.window) -> None:
|
||||
|
||||
logging.info("Initializing interface...")
|
||||
with app_state.lock:
|
||||
interface_state.interface = initialize_interface(args)
|
||||
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
|
||||
if interface_state.interface is None:
|
||||
return
|
||||
|
||||
if interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||
prompt_region_if_unset(args)
|
||||
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||
prompt_region_if_unset(args, stdscr)
|
||||
|
||||
initialize_globals()
|
||||
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
|
||||
logging.info("Starting main UI")
|
||||
|
||||
stdscr.clear()
|
||||
@@ -120,6 +180,24 @@ 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()
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Entry point for the application."""
|
||||
|
||||
@@ -129,10 +207,10 @@ def start() -> None:
|
||||
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
interface_state.interface.close()
|
||||
close_interface(interface_state.interface)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("User exited with Ctrl+C")
|
||||
interface_state.interface.close()
|
||||
close_interface(interface_state.interface)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logging.critical("Fatal error", exc_info=True)
|
||||
|
||||
@@ -1,10 +1,149 @@
|
||||
#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+/ or / = 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."
|
||||
@@ -256,6 +395,7 @@ 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"
|
||||
@@ -317,4 +457,4 @@ 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"
|
||||
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
|
||||
|
||||
460
contact/localisations/ru.ini
Normal file
460
contact/localisations/ru.ini
Normal file
@@ -0,0 +1,460 @@
|
||||
#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"
|
||||
@@ -5,9 +5,10 @@ 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: threading.Timer | None = None
|
||||
_sound_timer: Optional[threading.Timer] = None
|
||||
_sound_timer_lock = threading.Lock()
|
||||
_last_sound_request = 0.0
|
||||
|
||||
@@ -42,18 +43,13 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
|
||||
_sound_timer = threading.Timer(delay, _fire, args=(now,))
|
||||
_sound_timer.daemon = True
|
||||
_sound_timer.start()
|
||||
from typing import Any, Dict
|
||||
|
||||
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,
|
||||
request_ui_redraw,
|
||||
)
|
||||
from contact.utilities.db_handler import (
|
||||
save_message_to_db,
|
||||
@@ -122,7 +118,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
|
||||
|
||||
if ui_state.display_log:
|
||||
draw_packetlog_win()
|
||||
request_ui_redraw(packetlog=True)
|
||||
|
||||
if ui_state.current_window == 4:
|
||||
menu_state.need_redraw = True
|
||||
@@ -133,7 +129,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
# Assume any incoming packet could update the last seen time for a node
|
||||
changed = refresh_node_list()
|
||||
if changed:
|
||||
draw_node_list()
|
||||
request_ui_redraw(nodes=True)
|
||||
|
||||
if packet["decoded"]["portnum"] == "NODEINFO_APP":
|
||||
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
|
||||
@@ -187,9 +183,9 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
|
||||
|
||||
if refresh_channels:
|
||||
draw_channel_list()
|
||||
request_ui_redraw(channels=True)
|
||||
if refresh_messages:
|
||||
draw_messages_window(True)
|
||||
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
|
||||
|
||||
save_message_to_db(channel_id, message_from_id, message_string)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from contact.utilities.db_handler import (
|
||||
)
|
||||
import contact.ui.default_config as config
|
||||
|
||||
from contact.utilities.singleton import ui_state, interface_state
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||
|
||||
from contact.utilities.utils import add_new_message
|
||||
|
||||
@@ -28,145 +28,141 @@ def onAckNak(packet: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Handles incoming ACK/NAK response packets.
|
||||
"""
|
||||
from contact.ui.contact_ui import draw_messages_window
|
||||
from contact.ui.contact_ui import request_ui_redraw
|
||||
|
||||
request = packet["decoded"]["requestId"]
|
||||
if request not in ack_naks:
|
||||
return
|
||||
with app_state.lock:
|
||||
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]
|
||||
acknak = ack_naks.pop(request)
|
||||
message = ui_state.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
|
||||
confirm_string = config.ack_implicit_str
|
||||
ack_type = "Implicit"
|
||||
confirm_string = " "
|
||||
ack_type = None
|
||||
if packet["decoded"]["routing"]["errorReason"] == "NONE":
|
||||
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
|
||||
confirm_string = config.ack_implicit_str
|
||||
ack_type = "Implicit"
|
||||
else:
|
||||
confirm_string = config.ack_str
|
||||
ack_type = "Ack"
|
||||
else:
|
||||
confirm_string = config.ack_str
|
||||
ack_type = "Ack"
|
||||
else:
|
||||
confirm_string = config.nak_str
|
||||
ack_type = "Nak"
|
||||
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,
|
||||
)
|
||||
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
|
||||
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
|
||||
message,
|
||||
)
|
||||
|
||||
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
|
||||
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
|
||||
|
||||
channel_number = ui_state.channel_list.index(acknak["channel"])
|
||||
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
|
||||
draw_messages_window()
|
||||
channel_number = ui_state.channel_list.index(acknak["channel"])
|
||||
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
|
||||
request_ui_redraw(messages=True)
|
||||
|
||||
|
||||
def on_response_traceroute(packet: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle traceroute response packets and render the route visually in the UI.
|
||||
"""
|
||||
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
|
||||
from contact.ui.contact_ui import add_notification, request_ui_redraw
|
||||
|
||||
refresh_channels = False
|
||||
refresh_messages = False
|
||||
with app_state.lock:
|
||||
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"])
|
||||
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
|
||||
route_discovery = mesh_pb2.RouteDiscovery()
|
||||
route_discovery.ParseFromString(packet["decoded"]["payload"])
|
||||
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
|
||||
|
||||
msg_str = "Traceroute to:\n"
|
||||
msg_str = "Traceroute to:\n"
|
||||
|
||||
route_str = (
|
||||
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
|
||||
) # Start with destination of response
|
||||
|
||||
# SNR list should have one more entry than the route, as the final destination adds its SNR also
|
||||
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
|
||||
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
|
||||
if lenTowards > 0: # Loop through hops in route and add SNR if available
|
||||
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)"
|
||||
)
|
||||
|
||||
# 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)"
|
||||
)
|
||||
|
||||
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
|
||||
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
|
||||
) # Start with destination of response
|
||||
|
||||
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
|
||||
for idx, node_num in enumerate(msg_dict["routeBack"]):
|
||||
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:
|
||||
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["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
|
||||
+ (
|
||||
str(msg_dict["snrTowards"][idx] / 4)
|
||||
if snrTowardsValid and msg_dict["snrTowards"][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}")
|
||||
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
|
||||
+ " ("
|
||||
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
|
||||
+ (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 back to us
|
||||
msg_str += route_str + "\n"
|
||||
|
||||
if packet["from"] not in ui_state.channel_list:
|
||||
ui_state.channel_list.append(packet["from"])
|
||||
refresh_channels = True
|
||||
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}"
|
||||
|
||||
if is_chat_archived(packet["from"]):
|
||||
update_node_info_in_db(packet["from"], chat_archived=False)
|
||||
if lenBack > 0:
|
||||
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)"
|
||||
)
|
||||
|
||||
channel_number = ui_state.channel_list.index(packet["from"])
|
||||
channel_id = ui_state.channel_list[channel_number]
|
||||
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)"
|
||||
)
|
||||
|
||||
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
|
||||
refresh_messages = True
|
||||
else:
|
||||
add_notification(channel_number)
|
||||
refresh_channels = True
|
||||
msg_str += route_str + "\n"
|
||||
|
||||
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
|
||||
if packet["from"] not in ui_state.channel_list:
|
||||
ui_state.channel_list.append(packet["from"])
|
||||
refresh_channels = True
|
||||
|
||||
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
|
||||
if is_chat_archived(packet["from"]):
|
||||
update_node_info_in_db(packet["from"], chat_archived=False)
|
||||
|
||||
if refresh_channels:
|
||||
draw_channel_list()
|
||||
if refresh_messages:
|
||||
draw_messages_window(True)
|
||||
channel_number = ui_state.channel_list.index(packet["from"])
|
||||
channel_id = ui_state.channel_list[channel_number]
|
||||
|
||||
save_message_to_db(channel_id, packet["from"], msg_str)
|
||||
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
|
||||
refresh_messages = True
|
||||
else:
|
||||
add_notification(channel_number)
|
||||
refresh_channels = True
|
||||
|
||||
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
|
||||
|
||||
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
|
||||
|
||||
if refresh_channels:
|
||||
request_ui_redraw(channels=True)
|
||||
if refresh_messages:
|
||||
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
|
||||
|
||||
save_message_to_db(channel_id, packet["from"], msg_str)
|
||||
|
||||
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
|
||||
"""
|
||||
|
||||
@@ -7,11 +7,14 @@ 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
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.interfaces import initialize_interface
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
@@ -19,6 +22,7 @@ def main(stdscr: curses.window) -> None:
|
||||
try:
|
||||
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)
|
||||
@@ -28,11 +32,16 @@ def main(stdscr: curses.window) -> None:
|
||||
interface = initialize_interface(args)
|
||||
|
||||
if interface.localNode.localConfig.lora.region == 0:
|
||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||
confirmation = get_list_input(
|
||||
t("ui.confirm.region_unset", default="Your region is UNSET. Set it now?"),
|
||||
"Yes",
|
||||
["Yes", "No"],
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
set_region(interface)
|
||||
interface.close()
|
||||
interface = initialize_interface(args)
|
||||
draw_splash(stdscr)
|
||||
interface = reconnect_interface(args)
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
settings_menu(stdscr, interface)
|
||||
@@ -45,6 +54,24 @@ 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
|
||||
filename=config.log_file_path,
|
||||
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
|
||||
@@ -11,14 +11,72 @@ from contact.utilities.utils import parse_protobuf
|
||||
from contact.ui.colors import get_color
|
||||
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.emoji_utils import normalize_message_text
|
||||
import contact.ui.default_config as config
|
||||
import contact.ui.dialog
|
||||
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
|
||||
from contact.utilities.singleton import ui_state, interface_state, menu_state
|
||||
from contact.ui.nav_utils import (
|
||||
move_main_highlight,
|
||||
draw_main_arrows,
|
||||
get_msg_window_lines,
|
||||
wrap_text,
|
||||
truncate_with_ellipsis,
|
||||
pad_to_width,
|
||||
)
|
||||
from contact.utilities.singleton import ui_state, interface_state, menu_state, app_state
|
||||
|
||||
|
||||
MIN_COL = 1 # "effectively zero" without breaking curses
|
||||
RESIZE_DEBOUNCE_MS = 250
|
||||
root_win = None
|
||||
nodes_pad = None
|
||||
|
||||
|
||||
def request_ui_redraw(
|
||||
*,
|
||||
channels: bool = False,
|
||||
messages: bool = False,
|
||||
nodes: bool = False,
|
||||
packetlog: bool = False,
|
||||
full: bool = False,
|
||||
scroll_messages_to_bottom: bool = False,
|
||||
) -> None:
|
||||
ui_state.redraw_channels = ui_state.redraw_channels or channels
|
||||
ui_state.redraw_messages = ui_state.redraw_messages or messages
|
||||
ui_state.redraw_nodes = ui_state.redraw_nodes or nodes
|
||||
ui_state.redraw_packetlog = ui_state.redraw_packetlog or packetlog
|
||||
ui_state.redraw_full_ui = ui_state.redraw_full_ui or full
|
||||
ui_state.scroll_messages_to_bottom = ui_state.scroll_messages_to_bottom or scroll_messages_to_bottom
|
||||
|
||||
|
||||
def process_pending_ui_updates(stdscr: curses.window) -> None:
|
||||
if ui_state.redraw_full_ui:
|
||||
ui_state.redraw_full_ui = False
|
||||
ui_state.redraw_channels = False
|
||||
ui_state.redraw_messages = False
|
||||
ui_state.redraw_nodes = False
|
||||
ui_state.redraw_packetlog = False
|
||||
ui_state.scroll_messages_to_bottom = False
|
||||
handle_resize(stdscr, False)
|
||||
return
|
||||
|
||||
if ui_state.redraw_channels:
|
||||
ui_state.redraw_channels = False
|
||||
draw_channel_list()
|
||||
|
||||
if ui_state.redraw_nodes:
|
||||
ui_state.redraw_nodes = False
|
||||
draw_node_list()
|
||||
|
||||
if ui_state.redraw_messages:
|
||||
scroll_to_bottom = ui_state.scroll_messages_to_bottom
|
||||
ui_state.redraw_messages = False
|
||||
ui_state.scroll_messages_to_bottom = False
|
||||
draw_messages_window(scroll_to_bottom)
|
||||
|
||||
if ui_state.redraw_packetlog:
|
||||
ui_state.redraw_packetlog = False
|
||||
draw_packetlog_win()
|
||||
|
||||
|
||||
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
||||
@@ -61,6 +119,109 @@ def paint_frame(win, selected: bool) -> None:
|
||||
win.refresh()
|
||||
|
||||
|
||||
def get_channel_row_color(index: int) -> int:
|
||||
if index == ui_state.selected_channel:
|
||||
if ui_state.current_window == 0:
|
||||
return get_color("channel_list", reverse=True)
|
||||
return get_color("channel_selected")
|
||||
return get_color("channel_list")
|
||||
|
||||
|
||||
def get_node_row_color(index: int, highlight: bool = False) -> int:
|
||||
node_num = ui_state.node_list[index]
|
||||
node = interface_state.interface.nodesByNum.get(node_num, {})
|
||||
color = "node_list"
|
||||
if node.get("isFavorite"):
|
||||
color = "node_favorite"
|
||||
if node.get("isIgnored"):
|
||||
color = "node_ignored"
|
||||
reverse = index == ui_state.selected_node and (ui_state.current_window == 2 or highlight)
|
||||
return get_color(color, reverse=reverse)
|
||||
|
||||
|
||||
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
|
||||
if nodes_pad is None or not ui_state.node_list:
|
||||
return
|
||||
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 2)
|
||||
|
||||
if 0 <= old_index < len(ui_state.node_list):
|
||||
try:
|
||||
nodes_pad.chgat(old_index, 1, width, get_node_row_color(old_index, highlight=highlight))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
if 0 <= ui_state.selected_node < len(ui_state.node_list):
|
||||
try:
|
||||
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node, highlight=highlight))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
|
||||
refresh_pad(2)
|
||||
draw_window_arrows(2)
|
||||
|
||||
|
||||
def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
if window_id == 0:
|
||||
paint_frame(channel_win, selected=selected)
|
||||
if ui_state.channel_list:
|
||||
width = max(0, channel_pad.getmaxyx()[1] - 4)
|
||||
channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel))
|
||||
refresh_pad(0)
|
||||
elif window_id == 1:
|
||||
paint_frame(messages_win, selected=selected)
|
||||
refresh_pad(1)
|
||||
elif window_id == 2:
|
||||
paint_frame(nodes_win, selected=selected)
|
||||
if ui_state.node_list and nodes_pad is not None:
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 2)
|
||||
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node))
|
||||
refresh_pad(2)
|
||||
|
||||
|
||||
def get_node_display_name(node_num: int, node: dict) -> str:
|
||||
user = node.get("user") or {}
|
||||
return user.get("longName") or get_name_from_database(node_num, "long")
|
||||
|
||||
|
||||
def get_selected_channel_title() -> str:
|
||||
if not ui_state.channel_list:
|
||||
return ""
|
||||
|
||||
channel = ui_state.channel_list[min(ui_state.selected_channel, len(ui_state.channel_list) - 1)]
|
||||
if isinstance(channel, int):
|
||||
return get_name_from_database(channel, "long") or get_name_from_database(channel, "short") or str(channel)
|
||||
return str(channel)
|
||||
|
||||
|
||||
def get_window_title(window: int) -> str:
|
||||
if window == 2:
|
||||
return f"Nodes: {len(ui_state.node_list)}"
|
||||
if ui_state.single_pane_mode and window == 1:
|
||||
return get_selected_channel_title()
|
||||
return ""
|
||||
|
||||
|
||||
def draw_frame_title(box: curses.window, title: str) -> None:
|
||||
if not title:
|
||||
return
|
||||
|
||||
_, box_w = box.getmaxyx()
|
||||
max_title_width = max(0, box_w - 6)
|
||||
if max_title_width <= 0:
|
||||
return
|
||||
|
||||
clipped_title = truncate_with_ellipsis(title, max_title_width).rstrip()
|
||||
if not clipped_title:
|
||||
return
|
||||
|
||||
try:
|
||||
box.addstr(0, 2, f" {clipped_title} ", curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
"""Handle terminal resize events and redraw the UI accordingly."""
|
||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
||||
@@ -69,11 +230,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
if ui_state.single_pane_mode:
|
||||
channel_width, messages_width, nodes_width = compute_widths(width, ui_state.current_window)
|
||||
channel_width = width
|
||||
messages_width = width
|
||||
nodes_width = width
|
||||
channel_x = 0
|
||||
messages_x = 0
|
||||
nodes_x = 0
|
||||
else:
|
||||
channel_width = int(config.channel_list_16ths) * (width // 16)
|
||||
nodes_width = int(config.node_list_16ths) * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
channel_x = 0
|
||||
messages_x = channel_width
|
||||
nodes_x = channel_width + messages_width
|
||||
|
||||
channel_width = max(MIN_COL, channel_width)
|
||||
messages_width = max(MIN_COL, messages_width)
|
||||
@@ -81,7 +250,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
|
||||
# Ensure the three widths sum exactly to the terminal width by adjusting the focused pane
|
||||
total = channel_width + messages_width + nodes_width
|
||||
if total != width:
|
||||
if not ui_state.single_pane_mode and total != width:
|
||||
delta = total - width
|
||||
if ui_state.current_window == 0:
|
||||
channel_width = max(MIN_COL, channel_width - delta)
|
||||
@@ -98,11 +267,11 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
if firstrun:
|
||||
entry_win = curses.newwin(entry_height, width, height - entry_height, 0)
|
||||
|
||||
channel_win = curses.newwin(content_h, channel_width, 0, 0)
|
||||
messages_win = curses.newwin(content_h, messages_width, 0, channel_width)
|
||||
nodes_win = curses.newwin(content_h, nodes_width, 0, channel_width + messages_width)
|
||||
channel_win = curses.newwin(content_h, channel_width, 0, channel_x)
|
||||
messages_win = curses.newwin(content_h, messages_width, 0, messages_x)
|
||||
nodes_win = curses.newwin(content_h, nodes_width, 0, nodes_x)
|
||||
|
||||
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, channel_width)
|
||||
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, messages_x)
|
||||
|
||||
# Will be resized to what we need when drawn
|
||||
messages_pad = curses.newpad(1, 1)
|
||||
@@ -129,23 +298,30 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
entry_win.mvwin(height - entry_height, 0)
|
||||
|
||||
channel_win.resize(content_h, channel_width)
|
||||
channel_win.mvwin(0, 0)
|
||||
channel_win.mvwin(0, channel_x)
|
||||
|
||||
messages_win.resize(content_h, messages_width)
|
||||
messages_win.mvwin(0, channel_width)
|
||||
messages_win.mvwin(0, messages_x)
|
||||
|
||||
nodes_win.resize(content_h, nodes_width)
|
||||
nodes_win.mvwin(0, channel_width + messages_width)
|
||||
nodes_win.mvwin(0, nodes_x)
|
||||
|
||||
packetlog_win.resize(pkt_h, messages_width)
|
||||
packetlog_win.mvwin(height - pkt_h - entry_height, channel_width)
|
||||
packetlog_win.mvwin(height - pkt_h - entry_height, messages_x)
|
||||
|
||||
# Draw window borders
|
||||
for win in [channel_win, entry_win, nodes_win, messages_win]:
|
||||
windows_to_draw = [entry_win]
|
||||
if ui_state.single_pane_mode:
|
||||
windows_to_draw.append([channel_win, messages_win, nodes_win][ui_state.current_window])
|
||||
else:
|
||||
windows_to_draw.extend([channel_win, nodes_win, messages_win])
|
||||
|
||||
for win in windows_to_draw:
|
||||
win.box()
|
||||
win.refresh()
|
||||
|
||||
entry_win.keypad(True)
|
||||
entry_win.timeout(200)
|
||||
curses.curs_set(1)
|
||||
|
||||
try:
|
||||
@@ -160,6 +336,24 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def drain_resize_events(input_win: curses.window) -> Union[str, int, None]:
|
||||
"""Wait for resize events to settle and preserve one queued non-resize key."""
|
||||
input_win.timeout(RESIZE_DEBOUNCE_MS)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
next_char = input_win.get_wch()
|
||||
except curses.error:
|
||||
return None
|
||||
|
||||
if next_char == curses.KEY_RESIZE:
|
||||
continue
|
||||
|
||||
return next_char
|
||||
finally:
|
||||
input_win.timeout(-1)
|
||||
|
||||
|
||||
def main_ui(stdscr: curses.window) -> None:
|
||||
"""Main UI loop for the curses interface."""
|
||||
global input_text
|
||||
@@ -167,15 +361,25 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
|
||||
root_win = stdscr
|
||||
input_text = ""
|
||||
queued_char = None
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
handle_resize(stdscr, True)
|
||||
|
||||
while True:
|
||||
with app_state.lock:
|
||||
process_pending_ui_updates(stdscr)
|
||||
draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
|
||||
# Get user input from entry window
|
||||
char = entry_win.get_wch()
|
||||
try:
|
||||
if queued_char is None:
|
||||
char = entry_win.get_wch()
|
||||
else:
|
||||
char = queued_char
|
||||
queued_char = None
|
||||
except curses.error:
|
||||
continue
|
||||
|
||||
# draw_debug(f"Keypress: {char}")
|
||||
|
||||
@@ -223,12 +427,16 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
queued_char = drain_resize_events(entry_win)
|
||||
handle_resize(stdscr, False)
|
||||
continue
|
||||
|
||||
elif char == chr(4): # Ctrl + D to delete current channel or node
|
||||
handle_ctrl_d()
|
||||
|
||||
elif char == chr(31): # Ctrl + / to search
|
||||
elif char == chr(31) or (
|
||||
char == "/" and not input_text and ui_state.current_window in (0, 2)
|
||||
): # Ctrl + / or / to search in channel/node lists
|
||||
handle_ctrl_fslash()
|
||||
|
||||
elif char == chr(11): # Ctrl + K for Help
|
||||
@@ -332,31 +540,16 @@ def handle_leftright(char: int) -> None:
|
||||
delta = -1 if char == curses.KEY_LEFT else 1
|
||||
old_window = ui_state.current_window
|
||||
ui_state.current_window = (ui_state.current_window + delta) % 3
|
||||
handle_resize(root_win, False)
|
||||
if ui_state.single_pane_mode:
|
||||
handle_resize(root_win, False)
|
||||
return
|
||||
|
||||
if old_window == 0:
|
||||
paint_frame(channel_win, selected=False)
|
||||
refresh_pad(0)
|
||||
if old_window == 1:
|
||||
paint_frame(messages_win, selected=False)
|
||||
refresh_pad(1)
|
||||
elif old_window == 2:
|
||||
paint_frame(nodes_win, selected=False)
|
||||
refresh_pad(2)
|
||||
refresh_main_window(old_window, selected=False)
|
||||
|
||||
if not ui_state.single_pane_mode:
|
||||
draw_window_arrows(old_window)
|
||||
|
||||
if ui_state.current_window == 0:
|
||||
paint_frame(channel_win, selected=True)
|
||||
refresh_pad(0)
|
||||
elif ui_state.current_window == 1:
|
||||
paint_frame(messages_win, selected=True)
|
||||
refresh_pad(1)
|
||||
elif ui_state.current_window == 2:
|
||||
paint_frame(nodes_win, selected=True)
|
||||
refresh_pad(2)
|
||||
|
||||
refresh_main_window(ui_state.current_window, selected=True)
|
||||
draw_window_arrows(ui_state.current_window)
|
||||
|
||||
|
||||
@@ -377,31 +570,16 @@ def handle_function_keys(char: int) -> None:
|
||||
return
|
||||
|
||||
ui_state.current_window = target
|
||||
handle_resize(root_win, False)
|
||||
if ui_state.single_pane_mode:
|
||||
handle_resize(root_win, False)
|
||||
return
|
||||
|
||||
if old_window == 0:
|
||||
paint_frame(channel_win, selected=False)
|
||||
refresh_pad(0)
|
||||
elif old_window == 1:
|
||||
paint_frame(messages_win, selected=False)
|
||||
refresh_pad(1)
|
||||
elif old_window == 2:
|
||||
paint_frame(nodes_win, selected=False)
|
||||
refresh_pad(2)
|
||||
refresh_main_window(old_window, selected=False)
|
||||
|
||||
if not ui_state.single_pane_mode:
|
||||
draw_window_arrows(old_window)
|
||||
|
||||
if ui_state.current_window == 0:
|
||||
paint_frame(channel_win, selected=True)
|
||||
refresh_pad(0)
|
||||
elif ui_state.current_window == 1:
|
||||
paint_frame(messages_win, selected=True)
|
||||
refresh_pad(1)
|
||||
elif ui_state.current_window == 2:
|
||||
paint_frame(nodes_win, selected=True)
|
||||
refresh_pad(2)
|
||||
|
||||
refresh_main_window(ui_state.current_window, selected=True)
|
||||
draw_window_arrows(ui_state.current_window)
|
||||
|
||||
|
||||
@@ -433,7 +611,10 @@ def handle_enter(input_text: str) -> str:
|
||||
# TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node.
|
||||
now = time.monotonic()
|
||||
if now - ui_state.last_sent_time < 2.5:
|
||||
contact.ui.dialog.dialog("Slow down", "Please wait 2 seconds between messages.")
|
||||
contact.ui.dialog.dialog(
|
||||
t("ui.dialog.slow_down_title", default="Slow down"),
|
||||
t("ui.dialog.slow_down_body", default="Please wait 2 seconds between messages."),
|
||||
)
|
||||
return input_text
|
||||
# Enter key pressed, send user input as message
|
||||
send_message(input_text, channel=ui_state.selected_channel)
|
||||
@@ -450,34 +631,34 @@ def handle_enter(input_text: str) -> str:
|
||||
|
||||
|
||||
def handle_f5_key(stdscr: curses.window) -> None:
|
||||
node = None
|
||||
try:
|
||||
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
||||
if not ui_state.node_list:
|
||||
return
|
||||
|
||||
def build_node_details() -> tuple[str, list[str]]:
|
||||
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
||||
message_parts = []
|
||||
|
||||
message_parts.append("**📋 Basic Information:**")
|
||||
message_parts.append(f"• Device: {node.get('user', {}).get('longName', 'Unknown')}")
|
||||
message_parts.append(f"• Short name: {node.get('user', {}).get('shortName', 'Unknown')}")
|
||||
message_parts.append(f"• Hardware: {node.get('user', {}).get('hwModel', 'Unknown')}")
|
||||
|
||||
role = f"{node.get('user', {}).get('role', 'Unknown')}"
|
||||
message_parts.append(f"• Role: {role}")
|
||||
|
||||
pk = f"{node.get('user', {}).get('publicKey')}"
|
||||
message_parts.append(f"Public key: {pk}")
|
||||
|
||||
message_parts.append(f"• Role: {node.get('user', {}).get('role', 'Unknown')}")
|
||||
message_parts.append(f"Public key: {node.get('user', {}).get('publicKey')}")
|
||||
message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}")
|
||||
|
||||
if "position" in node:
|
||||
pos = node["position"]
|
||||
if pos.get("latitude") and pos.get("longitude"):
|
||||
has_coords = pos.get("latitude") and pos.get("longitude")
|
||||
if has_coords:
|
||||
message_parts.append(f"• Position: {pos['latitude']:.4f}, {pos['longitude']:.4f}")
|
||||
if pos.get("altitude"):
|
||||
message_parts.append(f"• Altitude: {pos['altitude']}m")
|
||||
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
|
||||
if has_coords:
|
||||
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
|
||||
|
||||
if any(key in node for key in ["snr", "hopsAway", "lastHeard"]):
|
||||
message_parts.append("\n**🌐 Network Metrics:**")
|
||||
message_parts.append("")
|
||||
message_parts.append("**🌐 Network Metrics:**")
|
||||
|
||||
if "snr" in node:
|
||||
snr = node["snr"]
|
||||
@@ -497,12 +678,13 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
|
||||
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
|
||||
|
||||
if "lastHeard" in node and node["lastHeard"]:
|
||||
if node.get("lastHeard"):
|
||||
message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}")
|
||||
|
||||
if node.get("deviceMetrics"):
|
||||
metrics = node["deviceMetrics"]
|
||||
message_parts.append("\n**📊 Device Metrics:**")
|
||||
message_parts.append("")
|
||||
message_parts.append("**📊 Device Metrics:**")
|
||||
|
||||
if "batteryLevel" in metrics:
|
||||
battery = metrics["batteryLevel"]
|
||||
@@ -523,14 +705,128 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
|
||||
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
title = t(
|
||||
"ui.dialog.node_details_title",
|
||||
default="📡 Node Details: {name}",
|
||||
name=node.get("user", {}).get("shortName", "Unknown"),
|
||||
)
|
||||
return title, message_parts
|
||||
|
||||
contact.ui.dialog.dialog(f"📡 Node Details: {node.get('user', {}).get('shortName', 'Unknown')}", message)
|
||||
curses.curs_set(1) # Show cursor again
|
||||
handle_resize(stdscr, False)
|
||||
previous_window = ui_state.current_window
|
||||
ui_state.current_window = 4
|
||||
scroll_offset = 0
|
||||
dialog_win = None
|
||||
|
||||
curses.curs_set(0)
|
||||
refresh_node_selection(highlight=True)
|
||||
|
||||
try:
|
||||
while True:
|
||||
curses.update_lines_cols()
|
||||
height, width = curses.LINES, curses.COLS
|
||||
title, message_lines = build_node_details()
|
||||
|
||||
max_line_length = max(len(title), *(len(line) for line in message_lines))
|
||||
dialog_width = min(max(max_line_length + 4, 20), max(10, width - 2))
|
||||
dialog_height = min(max(len(message_lines) + 4, 6), max(6, height - 2))
|
||||
x = max(0, (width - dialog_width) // 2)
|
||||
y = max(0, (height - dialog_height) // 2)
|
||||
viewport_h = max(1, dialog_height - 4)
|
||||
max_scroll = max(0, len(message_lines) - viewport_h)
|
||||
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||
|
||||
if dialog_win is None:
|
||||
dialog_win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||
else:
|
||||
dialog_win.erase()
|
||||
dialog_win.refresh()
|
||||
dialog_win.resize(dialog_height, dialog_width)
|
||||
dialog_win.mvwin(y, x)
|
||||
|
||||
dialog_win.keypad(True)
|
||||
dialog_win.bkgd(get_color("background"))
|
||||
dialog_win.attrset(get_color("window_frame"))
|
||||
dialog_win.border(0)
|
||||
|
||||
try:
|
||||
dialog_win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default"))
|
||||
hint = f" {ui_state.selected_node + 1}/{len(ui_state.node_list)} "
|
||||
dialog_win.addstr(0, max(2, dialog_width - len(hint) - 2), hint, get_color("commands"))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
msg_win = dialog_win.derwin(viewport_h + 2, dialog_width - 2, 1, 1)
|
||||
msg_win.erase()
|
||||
|
||||
for row, line in enumerate(message_lines[scroll_offset : scroll_offset + viewport_h], start=1):
|
||||
trimmed = line[: max(0, dialog_width - 6)]
|
||||
try:
|
||||
msg_win.addstr(row, 1, trimmed, get_color("settings_default"))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
if len(message_lines) > viewport_h:
|
||||
old_index = ui_state.start_index[4] if len(ui_state.start_index) > 4 else 0
|
||||
while len(ui_state.start_index) <= 4:
|
||||
ui_state.start_index.append(0)
|
||||
ui_state.start_index[4] = scroll_offset
|
||||
draw_main_arrows(msg_win, len(message_lines) - 1, window=4)
|
||||
ui_state.start_index[4] = old_index
|
||||
|
||||
try:
|
||||
ok_text = " Up/Down: Nodes PgUp/PgDn: Scroll Esc: Close "
|
||||
dialog_win.addstr(
|
||||
dialog_height - 2,
|
||||
max(1, (dialog_width - len(ok_text)) // 2),
|
||||
ok_text[: max(0, dialog_width - 2)],
|
||||
get_color("settings_default", reverse=True),
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
dialog_win.refresh()
|
||||
msg_win.noutrefresh()
|
||||
curses.doupdate()
|
||||
|
||||
dialog_win.timeout(200)
|
||||
char = dialog_win.getch()
|
||||
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
continue
|
||||
|
||||
if char in (27, curses.KEY_LEFT, curses.KEY_ENTER, 10, 13, 32):
|
||||
break
|
||||
if char == curses.KEY_UP:
|
||||
old_selected_node = ui_state.selected_node
|
||||
ui_state.selected_node = (ui_state.selected_node - 1) % len(ui_state.node_list)
|
||||
scroll_offset = 0
|
||||
refresh_node_selection(old_selected_node, highlight=True)
|
||||
elif char == curses.KEY_DOWN:
|
||||
old_selected_node = ui_state.selected_node
|
||||
ui_state.selected_node = (ui_state.selected_node + 1) % len(ui_state.node_list)
|
||||
scroll_offset = 0
|
||||
refresh_node_selection(old_selected_node, highlight=True)
|
||||
elif char == curses.KEY_PPAGE:
|
||||
scroll_offset = max(0, scroll_offset - viewport_h)
|
||||
elif char == curses.KEY_NPAGE:
|
||||
scroll_offset = min(max_scroll, scroll_offset + viewport_h)
|
||||
elif char == curses.KEY_HOME:
|
||||
scroll_offset = 0
|
||||
elif char == curses.KEY_END:
|
||||
scroll_offset = max_scroll
|
||||
elif char == curses.KEY_RESIZE:
|
||||
continue
|
||||
|
||||
except KeyError:
|
||||
return
|
||||
finally:
|
||||
if dialog_win is not None:
|
||||
dialog_win.erase()
|
||||
dialog_win.refresh()
|
||||
ui_state.current_window = previous_window
|
||||
curses.curs_set(1)
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||
@@ -542,7 +838,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||
if remaining > 0:
|
||||
curses.curs_set(0) # Hide cursor
|
||||
contact.ui.dialog.dialog(
|
||||
"Traceroute Not Sent", f"Please wait {int(remaining)} seconds before sending another traceroute."
|
||||
t("ui.dialog.traceroute_not_sent_title", default="Traceroute Not Sent"),
|
||||
t(
|
||||
"ui.dialog.traceroute_not_sent_body",
|
||||
default="Please wait {seconds} seconds before sending another traceroute.",
|
||||
seconds=int(remaining),
|
||||
),
|
||||
)
|
||||
curses.curs_set(1) # Show cursor again
|
||||
handle_resize(stdscr, False)
|
||||
@@ -552,8 +853,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||
ui_state.last_traceroute_time = now
|
||||
curses.curs_set(0) # Hide cursor
|
||||
contact.ui.dialog.dialog(
|
||||
f"Traceroute Sent To: {get_name_from_database(ui_state.node_list[ui_state.selected_node])}",
|
||||
"Results will appear in messages window.",
|
||||
t(
|
||||
"ui.dialog.traceroute_sent_title",
|
||||
default="Traceroute Sent To: {name}",
|
||||
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
|
||||
),
|
||||
t("ui.dialog.traceroute_sent_body", default="Results will appear in messages window."),
|
||||
)
|
||||
curses.curs_set(1) # Show cursor again
|
||||
handle_resize(stdscr, False)
|
||||
@@ -578,7 +883,9 @@ def handle_backtick(stdscr: curses.window) -> None:
|
||||
ui_state.current_window = 4
|
||||
settings_menu(stdscr, interface_state.interface)
|
||||
ui_state.current_window = previous_window
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
curses.curs_set(1)
|
||||
get_channels()
|
||||
refresh_node_list()
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
@@ -601,23 +908,23 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
curses.curs_set(0)
|
||||
|
||||
cmds = [
|
||||
"↑/↓ = Scroll",
|
||||
"←/→ = Switch window",
|
||||
"F1/F2/F3 = Jump to Channel/Messages/Nodes",
|
||||
"ENTER = Send / Select",
|
||||
"` or F12 = Settings",
|
||||
"ESC = Quit",
|
||||
"Ctrl+P = Toggle Packet Log",
|
||||
"Ctrl+T or F4 = Traceroute",
|
||||
"F5 = Full node info",
|
||||
"Ctrl+D = Archive chat / remove node",
|
||||
"Ctrl+F = Favorite",
|
||||
"Ctrl+G = Ignore",
|
||||
"Ctrl+/ = Search",
|
||||
"Ctrl+K = Help",
|
||||
t("ui.help.scroll", default="Up/Down = Scroll"),
|
||||
t("ui.help.switch_window", default="Left/Right = Switch window"),
|
||||
t("ui.help.jump_windows", default="F1/F2/F3 = Jump to Channel/Messages/Nodes"),
|
||||
t("ui.help.enter", default="ENTER = Send / Select"),
|
||||
t("ui.help.settings", default="` or F12 = Settings"),
|
||||
t("ui.help.quit", default="ESC = Quit"),
|
||||
t("ui.help.packet_log", default="Ctrl+P = Toggle Packet Log"),
|
||||
t("ui.help.traceroute", default="Ctrl+T or F4 = Traceroute"),
|
||||
t("ui.help.node_info", default="F5 = Full node info"),
|
||||
t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"),
|
||||
t("ui.help.favorite", default="Ctrl+F = Favorite"),
|
||||
t("ui.help.ignore", default="Ctrl+G = Ignore"),
|
||||
t("ui.help.search", default="Ctrl+/ = Search"),
|
||||
t("ui.help.help", default="Ctrl+K = Help"),
|
||||
]
|
||||
|
||||
contact.ui.dialog.dialog("Help — Shortcut Keys", "\n".join(cmds))
|
||||
contact.ui.dialog.dialog(t("ui.dialog.help_title", default="Help - Shortcut Keys"), "\n".join(cmds))
|
||||
|
||||
curses.curs_set(1)
|
||||
handle_resize(stdscr, False)
|
||||
@@ -642,7 +949,11 @@ def handle_ctrl_d() -> None:
|
||||
if ui_state.current_window == 2:
|
||||
curses.curs_set(0)
|
||||
confirmation = get_list_input(
|
||||
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from nodedb?",
|
||||
t(
|
||||
"ui.confirm.remove_from_nodedb",
|
||||
default="Remove {name} from nodedb?",
|
||||
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
|
||||
),
|
||||
"No",
|
||||
["Yes", "No"],
|
||||
)
|
||||
@@ -680,7 +991,11 @@ def handle_ctrl_f(stdscr: curses.window) -> None:
|
||||
|
||||
if "isFavorite" not in selectedNode or selectedNode["isFavorite"] == False:
|
||||
confirmation = get_list_input(
|
||||
f"Set {get_name_from_database(ui_state.node_list[ui_state.selected_node])} as Favorite?",
|
||||
t(
|
||||
"ui.confirm.set_favorite",
|
||||
default="Set {name} as Favorite?",
|
||||
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
|
||||
),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
@@ -693,7 +1008,11 @@ def handle_ctrl_f(stdscr: curses.window) -> None:
|
||||
|
||||
else:
|
||||
confirmation = get_list_input(
|
||||
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Favorites?",
|
||||
t(
|
||||
"ui.confirm.remove_favorite",
|
||||
default="Remove {name} from Favorites?",
|
||||
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
|
||||
),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
@@ -716,7 +1035,11 @@ def handle_ctlr_g(stdscr: curses.window) -> None:
|
||||
|
||||
if "isIgnored" not in selectedNode or selectedNode["isIgnored"] == False:
|
||||
confirmation = get_list_input(
|
||||
f"Set {get_name_from_database(ui_state.node_list[ui_state.selected_node])} as Ignored?",
|
||||
t(
|
||||
"ui.confirm.set_ignored",
|
||||
default="Set {name} as Ignored?",
|
||||
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
|
||||
),
|
||||
"No",
|
||||
["Yes", "No"],
|
||||
)
|
||||
@@ -725,7 +1048,11 @@ def handle_ctlr_g(stdscr: curses.window) -> None:
|
||||
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isIgnored"] = True
|
||||
else:
|
||||
confirmation = get_list_input(
|
||||
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Ignored?",
|
||||
t(
|
||||
"ui.confirm.remove_ignored",
|
||||
default="Remove {name} from Ignored?",
|
||||
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
|
||||
),
|
||||
"No",
|
||||
["Yes", "No"],
|
||||
)
|
||||
@@ -745,7 +1072,7 @@ def draw_channel_list() -> None:
|
||||
channel_pad.erase()
|
||||
win_width = channel_win.getmaxyx()[1]
|
||||
|
||||
channel_pad.resize(len(ui_state.all_messages), channel_win.getmaxyx()[1])
|
||||
channel_pad.resize(max(1, len(ui_state.channel_list)), channel_win.getmaxyx()[1])
|
||||
|
||||
idx = 0
|
||||
for channel in ui_state.channel_list:
|
||||
@@ -762,9 +1089,7 @@ def draw_channel_list() -> None:
|
||||
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
|
||||
|
||||
# Truncate the channel name if it's too long to fit in the window
|
||||
truncated_channel = (
|
||||
(channel[: win_width - 5] + "-" if len(channel) > win_width - 5 else channel) + notification
|
||||
).ljust(win_width - 3)
|
||||
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 3)
|
||||
|
||||
color = get_color("channel_list")
|
||||
if idx == ui_state.selected_channel:
|
||||
@@ -799,7 +1124,7 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
||||
|
||||
row = 0
|
||||
for prefix, message in messages:
|
||||
full_message = f"{prefix}{message}"
|
||||
full_message = normalize_message_text(f"{prefix}{message}")
|
||||
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
|
||||
msg_line_count += len(wrapped_lines)
|
||||
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
||||
@@ -841,10 +1166,8 @@ def draw_node_list() -> None:
|
||||
if ui_state.current_window != 2 and ui_state.single_pane_mode:
|
||||
return
|
||||
|
||||
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
|
||||
# if nodes_pad is None:
|
||||
# nodes_pad = curses.newpad(1, 1)
|
||||
nodes_pad = curses.newpad(1, 1)
|
||||
if nodes_pad is None:
|
||||
nodes_pad = curses.newpad(1, 1)
|
||||
|
||||
try:
|
||||
nodes_pad.erase()
|
||||
@@ -858,28 +1181,11 @@ def draw_node_list() -> None:
|
||||
node = interface_state.interface.nodesByNum[node_num]
|
||||
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
|
||||
status_icon = "🔐" if secure else "🔓"
|
||||
node_name = get_name_from_database(node_num, "long")
|
||||
user_name = node["user"]["shortName"]
|
||||
|
||||
uptime_str = ""
|
||||
if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"]:
|
||||
uptime_str = f" / Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}"
|
||||
|
||||
last_heard_str = f" ■ {get_time_ago(node['lastHeard'])}" if node.get("lastHeard") else ""
|
||||
hops_str = f" ■ Hops: {node['hopsAway']}" if "hopsAway" in node else ""
|
||||
snr_str = f" ■ SNR: {node['snr']}dB" if node.get("hopsAway") == 0 and "snr" in node else ""
|
||||
node_name = get_node_display_name(node_num, node)
|
||||
|
||||
# Future node name custom formatting possible
|
||||
node_str = f"{status_icon} {node_name}"
|
||||
node_str = node_str.ljust(box_width - 4)[: box_width - 2]
|
||||
color = "node_list"
|
||||
if "isFavorite" in node and node["isFavorite"]:
|
||||
color = "node_favorite"
|
||||
if "isIgnored" in node and node["isIgnored"]:
|
||||
color = "node_ignored"
|
||||
nodes_pad.addstr(
|
||||
i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2)
|
||||
)
|
||||
node_str = pad_to_width(f"{status_icon} {node_name}", box_width - 2)
|
||||
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
|
||||
|
||||
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
||||
nodes_win.refresh()
|
||||
@@ -1100,8 +1406,7 @@ def refresh_pad(window: int) -> None:
|
||||
pad = messages_pad
|
||||
box = messages_win
|
||||
lines = get_msg_window_lines(messages_win, packetlog_win)
|
||||
selected_item = ui_state.selected_message
|
||||
start_index = ui_state.selected_message
|
||||
start_index = ui_state.start_index[1]
|
||||
|
||||
if ui_state.display_log:
|
||||
packetlog_win.box()
|
||||
@@ -1111,7 +1416,6 @@ def refresh_pad(window: int) -> None:
|
||||
pad = nodes_pad
|
||||
box = nodes_win
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
box.addstr(0, 2, (f"Nodes: {len(ui_state.node_list)}"), curses.A_BOLD)
|
||||
selected_item = ui_state.selected_node
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
@@ -1148,6 +1452,9 @@ def refresh_pad(window: int) -> None:
|
||||
if bottom < top or right < left:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.refresh()
|
||||
|
||||
pad.refresh(
|
||||
start_index,
|
||||
0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import curses
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -8,7 +9,10 @@ from typing import List
|
||||
from contact.utilities.save_to_radio import save_changes
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.config_io import config_export, config_import
|
||||
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
|
||||
from contact.utilities.interfaces import reconnect_interface
|
||||
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,
|
||||
@@ -20,8 +24,10 @@ 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.ui.splash import draw_splash
|
||||
from contact.ui.user_config import json_editor
|
||||
from contact.utilities.singleton import menu_state
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.singleton import interface_state, menu_state
|
||||
|
||||
# Setup Variables
|
||||
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||
@@ -45,7 +51,7 @@ 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 = os.path.join(parent_dir, "localisations", "en.ini")
|
||||
translation_file = config.get_localisation_file(config.language)
|
||||
|
||||
# config_folder = os.path.join(locals_dir, "node-configs")
|
||||
config_folder = os.path.abspath(config.node_configs_file_path)
|
||||
@@ -53,6 +59,34 @@ config_folder = os.path.abspath(config.node_configs_file_path)
|
||||
# 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:
|
||||
@@ -86,7 +120,7 @@ def display_menu() -> tuple[object, object]:
|
||||
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8)
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
header = " > ".join(word.title() for word in menu_state.menu_path)
|
||||
header = get_translated_header(menu_state.menu_path)
|
||||
if len(header) > w - 4:
|
||||
header = header[: w - 7] + "..."
|
||||
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
|
||||
@@ -99,6 +133,22 @@ def display_menu() -> tuple[object, object]:
|
||||
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]
|
||||
|
||||
@@ -113,10 +163,11 @@ def display_menu() -> tuple[object, object]:
|
||||
|
||||
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_option)) // 2,
|
||||
save_option,
|
||||
(w - len(save_label)) // 2,
|
||||
save_label,
|
||||
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
|
||||
)
|
||||
|
||||
@@ -174,6 +225,39 @@ def get_input_type_for_field(field) -> type:
|
||||
return str
|
||||
|
||||
|
||||
def reconnect_interface_with_splash(stdscr: object, interface: object) -> object:
|
||||
try:
|
||||
interface.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
draw_splash(stdscr)
|
||||
new_interface = reconnect_interface(setup_parser().parse_args())
|
||||
interface_state.interface = new_interface
|
||||
redraw_main_ui_after_reconnect(stdscr)
|
||||
return new_interface
|
||||
|
||||
|
||||
def reconnect_after_admin_action(stdscr: object, interface: object, action, log_message: str) -> object:
|
||||
action()
|
||||
logging.info(log_message)
|
||||
return reconnect_interface_with_splash(stdscr, interface)
|
||||
|
||||
|
||||
def redraw_main_ui_after_reconnect(stdscr: object) -> None:
|
||||
try:
|
||||
from contact.ui import contact_ui
|
||||
from contact.utilities.utils import get_channels, refresh_node_list
|
||||
|
||||
get_channels()
|
||||
refresh_node_list()
|
||||
contact_ui.handle_resize(stdscr, False)
|
||||
except Exception:
|
||||
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
|
||||
|
||||
|
||||
def settings_menu(stdscr: object, interface: object) -> None:
|
||||
curses.update_lines_cols()
|
||||
|
||||
@@ -282,9 +366,12 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
help_win.refresh()
|
||||
|
||||
if menu_state.show_save_option and menu_state.selected_index == len(options):
|
||||
save_changes(interface, modified_settings, menu_state)
|
||||
reconnect_required = save_changes(interface, modified_settings, menu_state)
|
||||
modified_settings.clear()
|
||||
logging.info("Changes Saved")
|
||||
if reconnect_required:
|
||||
interface = reconnect_interface_with_splash(stdscr, interface)
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
|
||||
if len(menu_state.menu_path) > 1:
|
||||
menu_state.menu_path.pop()
|
||||
@@ -301,7 +388,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
elif selected_option == "Export Config File":
|
||||
|
||||
filename = get_text_input("Enter a filename for the config file", None, None)
|
||||
filename = get_text_input(
|
||||
t("ui.prompt.config_filename", default="Enter a filename for the config file"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if not filename:
|
||||
logging.info("Export aborted: No filename provided.")
|
||||
menu_state.start_index.pop()
|
||||
@@ -314,7 +405,15 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
yaml_file_path = os.path.join(config_folder, filename)
|
||||
|
||||
if os.path.exists(yaml_file_path):
|
||||
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
|
||||
overwrite = get_list_input(
|
||||
t(
|
||||
"ui.confirm.overwrite_file",
|
||||
default="{filename} already exists. Overwrite?",
|
||||
filename=filename,
|
||||
),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "No":
|
||||
logging.info("Export cancelled: User chose not to overwrite.")
|
||||
menu_state.start_index.pop()
|
||||
@@ -323,7 +422,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
with open(yaml_file_path, "w", encoding="utf-8") as file:
|
||||
file.write(config_text)
|
||||
logging.info(f"Config file saved to {yaml_file_path}")
|
||||
dialog("Config File Saved:", yaml_file_path)
|
||||
dialog(t("ui.dialog.config_saved_title", default="Config File Saved:"), yaml_file_path)
|
||||
menu_state.need_redraw = True
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
@@ -340,7 +439,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
# Check if folder exists and is not empty
|
||||
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
|
||||
dialog("", " No config files found. Export a config first.")
|
||||
dialog("", t("ui.dialog.no_config_files", default=" No config files found. Export a config first."))
|
||||
menu_state.need_redraw = True
|
||||
continue # Return to menu
|
||||
|
||||
@@ -348,14 +447,24 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
# Ensure file_list is not empty before proceeding
|
||||
if not file_list:
|
||||
dialog("", " No config files found. Export a config first.")
|
||||
dialog("", t("ui.dialog.no_config_files", default=" No config files found. Export a config first."))
|
||||
menu_state.need_redraw = True
|
||||
continue
|
||||
|
||||
filename = get_list_input("Choose a config file", None, file_list)
|
||||
filename = get_list_input(
|
||||
t("ui.prompt.choose_config_file", default="Choose a config file"), None, file_list
|
||||
)
|
||||
if filename:
|
||||
file_path = os.path.join(config_folder, filename)
|
||||
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
|
||||
overwrite = get_list_input(
|
||||
t(
|
||||
"ui.confirm.load_config_file",
|
||||
default="Are you sure you want to load {filename}?",
|
||||
filename=filename,
|
||||
),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "Yes":
|
||||
config_import(interface, file_path)
|
||||
menu_state.start_index.pop()
|
||||
@@ -363,10 +472,22 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
elif selected_option == "Config URL":
|
||||
current_value = interface.localNode.getURL()
|
||||
new_value = get_text_input(f"Config URL is currently: {current_value}", None, str)
|
||||
new_value = get_text_input(
|
||||
t(
|
||||
"ui.prompt.config_url_current",
|
||||
default="Config URL is currently: {value}",
|
||||
value=current_value,
|
||||
),
|
||||
None,
|
||||
str,
|
||||
)
|
||||
if new_value is not None:
|
||||
current_value = new_value
|
||||
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
|
||||
overwrite = get_list_input(
|
||||
t("ui.confirm.load_config_url", default="Are you sure you want to load this config?"),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "Yes":
|
||||
interface.localNode.setURL(new_value)
|
||||
logging.info(f"New Config URL sent to node")
|
||||
@@ -374,23 +495,35 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
continue
|
||||
|
||||
elif selected_option == "Reboot":
|
||||
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
|
||||
confirmation = get_list_input(
|
||||
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.reboot()
|
||||
logging.info(f"Node Reboot Requested by menu")
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu"
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Reset Node DB":
|
||||
confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"])
|
||||
confirmation = get_list_input(
|
||||
t("ui.confirm.reset_node_db", default="Are you sure you want to Reset Node DB?"),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.resetNodeDb()
|
||||
logging.info(f"Node DB Reset Requested by menu")
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.resetNodeDb, "Node DB Reset Requested by menu"
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Shutdown":
|
||||
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
|
||||
confirmation = get_list_input(
|
||||
t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"]
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.shutdown()
|
||||
logging.info(f"Node Shutdown Requested by menu")
|
||||
@@ -398,10 +531,16 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
continue
|
||||
|
||||
elif selected_option == "Factory Reset":
|
||||
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
|
||||
confirmation = get_list_input(
|
||||
t("ui.confirm.factory_reset", default="Are you sure you want to Factory Reset?"),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.factoryReset()
|
||||
logging.info(f"Factory Reset Requested by menu")
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.factoryReset, "Factory Reset Requested by menu"
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
|
||||
@@ -411,6 +550,7 @@ 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()
|
||||
@@ -476,7 +616,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
new_value = new_value == "True" or new_value is True
|
||||
menu_state.start_index.pop()
|
||||
|
||||
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
|
||||
elif _is_repeated_field(field): # 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()
|
||||
@@ -548,7 +688,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
current_section = menu_state.menu_path[-1]
|
||||
save_prompt = get_list_input(
|
||||
f"You have unsaved changes in {current_section}. Save before exiting?",
|
||||
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,
|
||||
@@ -556,8 +700,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
if save_prompt == "Cancel":
|
||||
continue # Stay in the menu without doing anything
|
||||
elif save_prompt == "Yes":
|
||||
save_changes(interface, modified_settings, menu_state)
|
||||
reconnect_required = save_changes(interface, modified_settings, menu_state)
|
||||
logging.info("Changes Saved")
|
||||
if reconnect_required:
|
||||
interface = reconnect_interface_with_splash(stdscr, interface)
|
||||
|
||||
modified_settings.clear()
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
@@ -615,7 +761,9 @@ def set_region(interface: object) -> None:
|
||||
|
||||
regions = list(region_name_to_number.keys())
|
||||
|
||||
new_region_name = get_list_input("Select your region:", "UNSET", regions)
|
||||
new_region_name = get_list_input(
|
||||
t("ui.prompt.select_region", default="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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict
|
||||
from typing import Dict, List, Optional
|
||||
from contact.ui.colors import setup_colors
|
||||
|
||||
# Get the parent directory of the script
|
||||
@@ -65,6 +65,44 @@ 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:
|
||||
@@ -180,6 +218,8 @@ def initialize_config() -> Dict[str, object]:
|
||||
"node_favorite": ["cyan", "green"],
|
||||
"node_ignored": ["red", "black"],
|
||||
}
|
||||
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",
|
||||
@@ -187,6 +227,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"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": "*",
|
||||
@@ -230,7 +271,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
global db_file_path, log_file_path, node_configs_file_path, message_prefix, sent_message_prefix
|
||||
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
|
||||
global node_list_16ths, channel_list_16ths, single_pane_mode
|
||||
global theme, COLOR_CONFIG
|
||||
global theme, COLOR_CONFIG, language
|
||||
global node_sort, notification_sound
|
||||
|
||||
channel_list_16ths = loaded_config["channel_list_16ths"]
|
||||
@@ -239,6 +280,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
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"]
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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(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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
|
||||
from typing import Any, Union, Dict
|
||||
@@ -8,11 +7,6 @@ from typing import Any, Union, Dict
|
||||
from google.protobuf.message import Message
|
||||
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
|
||||
|
||||
|
||||
locals_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
translation_file = os.path.join(locals_dir, "localisations", "en.ini")
|
||||
|
||||
|
||||
def encode_if_bytes(value: Any) -> str:
|
||||
"""Encode byte values to base64 string."""
|
||||
if isinstance(value, bytes):
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from unicodedata import east_asian_width
|
||||
|
||||
from contact.ui.colors import get_color
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.control_utils import transform_menu_path
|
||||
from typing import Any, Optional, List, Dict
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
@@ -24,9 +25,11 @@ WrappedLine = List[Segment]
|
||||
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
save_option = "Save Changes"
|
||||
MIN_HEIGHT_FOR_HELP = 20
|
||||
|
||||
|
||||
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:
|
||||
@@ -54,6 +57,9 @@ def move_highlight(
|
||||
|
||||
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
|
||||
|
||||
@@ -74,8 +80,11 @@ def move_highlight(
|
||||
# Clear old selection
|
||||
if show_save_option and old_idx == max_index:
|
||||
win_h, win_w = menu_win.getmaxyx()
|
||||
menu_win.chgat(win_h - 2, (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save"))
|
||||
else:
|
||||
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,
|
||||
@@ -90,13 +99,14 @@ def move_highlight(
|
||||
# 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_option)) // 2,
|
||||
len(save_option),
|
||||
(win_w - len(save_label)) // 2,
|
||||
len(save_label),
|
||||
get_color("settings_save", reverse=True),
|
||||
)
|
||||
else:
|
||||
elif 0 <= new_idx < len(options):
|
||||
menu_pad.chgat(
|
||||
new_idx,
|
||||
0,
|
||||
@@ -169,9 +179,6 @@ def update_help_window(
|
||||
) -> object: # returns a curses window
|
||||
"""Handles rendering the help window consistently."""
|
||||
|
||||
if curses.LINES < MIN_HEIGHT_FOR_HELP:
|
||||
return None
|
||||
|
||||
# Clamp target position and width to the current terminal size
|
||||
help_x = max(0, help_x)
|
||||
help_y = max(0, help_y)
|
||||
@@ -233,7 +240,7 @@ def get_wrapped_help_text(
|
||||
"""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.")
|
||||
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
|
||||
|
||||
@@ -324,6 +331,51 @@ def text_width(text: str) -> int:
|
||||
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
|
||||
|
||||
|
||||
def slice_to_width(text: str, max_width: int) -> str:
|
||||
if max_width <= 0:
|
||||
return ""
|
||||
|
||||
width = 0
|
||||
chars = []
|
||||
for char in text:
|
||||
char_width = text_width(char)
|
||||
if width + char_width > max_width:
|
||||
break
|
||||
chars.append(char)
|
||||
width += char_width
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def pad_to_width(text: str, width: int) -> str:
|
||||
clipped = slice_to_width(text, width)
|
||||
return clipped + (" " * max(0, width - text_width(clipped)))
|
||||
|
||||
|
||||
def truncate_with_ellipsis(text: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ""
|
||||
if text_width(text) <= width:
|
||||
return pad_to_width(text, width)
|
||||
if width == 1:
|
||||
return "…"
|
||||
return pad_to_width(slice_to_width(text, width - 1) + "…", width)
|
||||
|
||||
|
||||
def split_text_to_width_chunks(text: str, width: int) -> List[str]:
|
||||
if width <= 0:
|
||||
return [""]
|
||||
|
||||
chunks = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
chunk = slice_to_width(remaining, width)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining = remaining[len(chunk) :]
|
||||
return chunks or [""]
|
||||
|
||||
|
||||
def wrap_text(text: str, wrap_width: int) -> List[str]:
|
||||
"""Wraps text while preserving spaces and breaking long words."""
|
||||
|
||||
@@ -346,8 +398,7 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
|
||||
wrapped_lines.append(line_buffer.strip())
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
for i in range(0, word_length, wrap_width):
|
||||
wrapped_lines.append(word[i : i + wrap_width])
|
||||
wrapped_lines.extend(split_text_to_width_chunks(word, wrap_width))
|
||||
continue
|
||||
|
||||
if line_length + word_length > wrap_width and word.strip():
|
||||
@@ -406,8 +457,8 @@ def highlight_line(
|
||||
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_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(old_idx))
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(new_idx, reverse=True))
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ class ChatUIState:
|
||||
show_save_option: bool = False
|
||||
menu_path: List[str] = field(default_factory=list)
|
||||
single_pane_mode: bool = False
|
||||
redraw_channels: bool = False
|
||||
redraw_messages: bool = False
|
||||
redraw_nodes: bool = False
|
||||
redraw_packetlog: bool = False
|
||||
redraw_full_ui: bool = False
|
||||
scroll_messages_to_bottom: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,18 +1,83 @@
|
||||
import os
|
||||
import json
|
||||
import curses
|
||||
from typing import Any, List, Dict
|
||||
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
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
|
||||
from contact.utilities.ini_utils import parse_ini_file
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
@@ -21,18 +86,34 @@ def get_effective_width() -> int:
|
||||
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
|
||||
|
||||
|
||||
def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
|
||||
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(f"Select Foreground Color for {key}", current_value[0], color_list)
|
||||
bg_color = get_list_input(f"Select Background Color for {key}", current_value[1], color_list)
|
||||
fg_color = get_list_input(
|
||||
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,
|
||||
)
|
||||
|
||||
return [fg_color, bg_color]
|
||||
|
||||
|
||||
def edit_value(key: str, current_value: str) -> str:
|
||||
def edit_value(key: str, display_label: str, current_value: str) -> str:
|
||||
|
||||
w = get_effective_width()
|
||||
height = 10
|
||||
@@ -47,8 +128,13 @@ def edit_value(key: str, current_value: str) -> str:
|
||||
edit_win.border()
|
||||
|
||||
# Display instructions
|
||||
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
|
||||
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
|
||||
edit_win.addstr(
|
||||
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"))
|
||||
|
||||
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)]
|
||||
@@ -64,22 +150,36 @@ def edit_value(key: str, current_value: str) -> str:
|
||||
theme_options = [
|
||||
k.split("_", 2)[2].lower() for k in config.loaded_config.keys() if k.startswith("COLOR_CONFIG")
|
||||
]
|
||||
return get_list_input("Select Theme", current_value, theme_options)
|
||||
return get_list_input(
|
||||
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,
|
||||
)
|
||||
|
||||
elif key == "node_sort":
|
||||
sort_options = ["lastHeard", "name", "hops"]
|
||||
return get_list_input("Sort By", current_value, sort_options)
|
||||
return get_list_input(display_label, current_value, sort_options)
|
||||
|
||||
elif key == "notification_sound":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input("Notification Sound", current_value, sound_options)
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
elif key == "single_pane_mode":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input("Single-Pane Mode", current_value, sound_options)
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
# Standard Input Mode (Scrollable)
|
||||
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
||||
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
|
||||
curses.curs_set(1)
|
||||
|
||||
scroll_offset = 0 # Determines which part of the text is visible
|
||||
@@ -100,11 +200,20 @@ def edit_value(key: str, current_value: str) -> str:
|
||||
edit_win.border()
|
||||
|
||||
# Redraw static content
|
||||
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
|
||||
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
|
||||
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, "New Value: ", 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"))
|
||||
@@ -147,6 +256,9 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
"""
|
||||
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
|
||||
@@ -158,11 +270,12 @@ 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, num_items + 5)
|
||||
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
|
||||
num_items = len(options)
|
||||
w = get_effective_width()
|
||||
start_y = (curses.LINES - menu_height) // 2
|
||||
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
|
||||
start_x = max(0, (curses.COLS - w) // 2)
|
||||
|
||||
# Create the window
|
||||
@@ -178,7 +291,7 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
# Display the menu path
|
||||
header = " > ".join(menu_state.menu_path)
|
||||
header = get_app_settings_header(menu_state.menu_path)
|
||||
if len(header) > w - 4:
|
||||
header = header[: w - 7] + "..."
|
||||
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
|
||||
@@ -190,7 +303,12 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
if isinstance(menu_state.current_menu, dict)
|
||||
else menu_state.current_menu[int(key.strip("[]"))]
|
||||
)
|
||||
display_key = f"{key}"[: w // 2 - 2]
|
||||
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))
|
||||
@@ -199,10 +317,11 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
# 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_option)) // 2,
|
||||
save_option,
|
||||
(w - len(save_label)) // 2,
|
||||
save_label,
|
||||
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
|
||||
)
|
||||
|
||||
@@ -221,9 +340,45 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
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 json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
|
||||
menu_state.selected_index = 0 # Track the selected option
|
||||
@@ -251,6 +406,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
|
||||
# Render the menu
|
||||
menu_win, menu_pad, options = display_menu()
|
||||
update_app_settings_help(menu_win, options)
|
||||
menu_state.need_redraw = True
|
||||
|
||||
while True:
|
||||
@@ -258,6 +414,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
menu_state.need_redraw = False
|
||||
menu_win, menu_pad, options = display_menu()
|
||||
menu_win.refresh()
|
||||
update_app_settings_help(menu_win, options)
|
||||
|
||||
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
|
||||
|
||||
@@ -271,6 +428,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
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)
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
|
||||
@@ -279,6 +437,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
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)
|
||||
|
||||
elif key == ord("\t") and menu_state.show_save_option:
|
||||
old_selected_index = menu_state.selected_index
|
||||
@@ -286,12 +445,16 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
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)
|
||||
|
||||
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
|
||||
|
||||
menu_state.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]
|
||||
@@ -308,10 +471,20 @@ 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, selected_data)
|
||||
new_value = edit_color_pair(selected_key, display_label, selected_data)
|
||||
menu_state.menu_path.pop()
|
||||
menu_state.start_index.pop()
|
||||
menu_state.menu_index.pop()
|
||||
@@ -327,7 +500,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
else:
|
||||
# General value editing
|
||||
old = selected_data
|
||||
new_value = edit_value(selected_key, selected_data)
|
||||
new_value = edit_value(selected_key, display_label, selected_data)
|
||||
menu_state.menu_path.pop()
|
||||
menu_state.menu_index.pop()
|
||||
menu_state.start_index.pop()
|
||||
@@ -349,6 +522,9 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
menu_state.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]
|
||||
|
||||
@@ -369,7 +545,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
# Exit the editor
|
||||
if made_changes:
|
||||
save_prompt = get_list_input(
|
||||
"You have unsaved changes. Save before exiting?",
|
||||
t("ui.confirm.save_before_exit", default="You have unsaved changes. Save before exiting?"),
|
||||
None,
|
||||
["Yes", "No", "Cancel"],
|
||||
mandatory=True,
|
||||
@@ -390,7 +566,8 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
||||
formatted_json = config.format_json_single_line_arrays(data)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(formatted_json)
|
||||
setup_colors(reinit=True)
|
||||
config.reload_config()
|
||||
reload_translations(data.get("language"))
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
|
||||
@@ -35,5 +35,10 @@ def setup_parser() -> ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--demo-screenshot",
|
||||
help="Launch with a fake interface and seeded demo data for screenshots/testing.",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
@@ -9,6 +9,17 @@ 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)
|
||||
@@ -89,7 +100,7 @@ def setPref(config, comp_name, raw_val) -> bool:
|
||||
return False
|
||||
|
||||
# repeating fields need to be handled with append, not setattr
|
||||
if pref.label != pref.LABEL_REPEATED:
|
||||
if not _is_repeated_field(pref):
|
||||
try:
|
||||
if config_type.message_type is not None:
|
||||
config_values = getattr(config_part, config_type.name)
|
||||
|
||||
@@ -1,55 +1,7 @@
|
||||
from typing import Optional, Tuple, Dict, List
|
||||
from typing import List
|
||||
import re
|
||||
|
||||
|
||||
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
|
||||
"""Parses an INI file and returns a mapping of keys to human-readable names and help text."""
|
||||
|
||||
field_mapping: Dict[str, str] = {}
|
||||
help_text: Dict[str, str] = {}
|
||||
current_section: 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 "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: List[str]) -> List[str]:
|
||||
"""Applies path replacements and normalizes entries in the menu path."""
|
||||
path_replacements = {"Radio Settings": "config", "Module Settings": "module"}
|
||||
|
||||
226
contact/utilities/demo_data.py
Normal file
226
contact/utilities/demo_data.py
Normal file
@@ -0,0 +1,226 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Tuple, Union
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.db_handler import get_table_name
|
||||
from contact.utilities.singleton import interface_state
|
||||
|
||||
|
||||
DEMO_DB_FILENAME = "contact_demo_client.db"
|
||||
DEMO_LOCAL_NODE_NUM = 0xC0DEC0DE
|
||||
DEMO_BASE_TIMESTAMP = 1738717200 # 2025-02-04 17:00:00 UTC
|
||||
DEMO_CHANNELS = ["MediumFast", "Another Channel"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemoChannelSettings:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemoChannel:
|
||||
role: bool
|
||||
settings: DemoChannelSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemoLoRaConfig:
|
||||
region: int = 1
|
||||
modem_preset: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemoLocalConfig:
|
||||
lora: DemoLoRaConfig
|
||||
|
||||
|
||||
class DemoLocalNode:
|
||||
def __init__(self, interface: "DemoInterface", channels: List[DemoChannel]) -> None:
|
||||
self._interface = interface
|
||||
self.channels = channels
|
||||
self.localConfig = DemoLocalConfig(lora=DemoLoRaConfig())
|
||||
|
||||
def setFavorite(self, node_num: int) -> None:
|
||||
self._interface.nodesByNum[node_num]["isFavorite"] = True
|
||||
|
||||
def removeFavorite(self, node_num: int) -> None:
|
||||
self._interface.nodesByNum[node_num]["isFavorite"] = False
|
||||
|
||||
def setIgnored(self, node_num: int) -> None:
|
||||
self._interface.nodesByNum[node_num]["isIgnored"] = True
|
||||
|
||||
def removeIgnored(self, node_num: int) -> None:
|
||||
self._interface.nodesByNum[node_num]["isIgnored"] = False
|
||||
|
||||
def removeNode(self, node_num: int) -> None:
|
||||
self._interface.nodesByNum.pop(node_num, None)
|
||||
|
||||
|
||||
class DemoInterface:
|
||||
def __init__(self, nodes: Dict[int, Dict[str, object]], channels: List[DemoChannel]) -> None:
|
||||
self.nodesByNum = nodes
|
||||
self.nodes = self.nodesByNum
|
||||
self.localNode = DemoLocalNode(self, channels)
|
||||
|
||||
def getMyNodeInfo(self) -> Dict[str, int]:
|
||||
return {"num": DEMO_LOCAL_NODE_NUM}
|
||||
|
||||
def getNode(self, selector: str) -> DemoLocalNode:
|
||||
if selector != "^local":
|
||||
raise KeyError(selector)
|
||||
return self.localNode
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
def build_demo_interface() -> DemoInterface:
|
||||
channels = [DemoChannel(role=True, settings=DemoChannelSettings(name=name)) for name in DEMO_CHANNELS]
|
||||
|
||||
nodes = {
|
||||
DEMO_LOCAL_NODE_NUM: _build_node(
|
||||
DEMO_LOCAL_NODE_NUM,
|
||||
"Meshtastic fb3c",
|
||||
"fb3c",
|
||||
hops=0,
|
||||
snr=13.7,
|
||||
last_heard_offset=5,
|
||||
battery=88,
|
||||
voltage=4.1,
|
||||
favorite=True,
|
||||
),
|
||||
0xA1000001: _build_node(0xA1000001, "KG7NDX-N2", "N2", hops=1, last_heard_offset=18, battery=79, voltage=4.0),
|
||||
0xA1000002: _build_node(0xA1000002, "Satellite II Repeater", "SAT2", hops=2, last_heard_offset=31),
|
||||
0xA1000003: _build_node(0xA1000003, "Search for Discord/Meshtastic", "DISC", hops=1, last_heard_offset=46),
|
||||
0xA1000004: _build_node(0xA1000004, "K7EOK Mobile", "MOBL", hops=1, last_heard_offset=63, battery=52),
|
||||
0xA1000005: _build_node(0xA1000005, "Turtle", "TRTL", hops=3, last_heard_offset=87),
|
||||
0xA1000006: _build_node(0xA1000006, "CARS Trewvilliger Plaza", "CARS", hops=2, last_heard_offset=121),
|
||||
0xA1000007: _build_node(0xA1000007, "No Hands!", "NHDS", hops=1, last_heard_offset=155),
|
||||
0xA1000008: _build_node(0xA1000008, "McCutie", "MCCU", hops=2, last_heard_offset=211, ignored=True),
|
||||
0xA1000009: _build_node(0xA1000009, "K1PDX", "K1PX", hops=2, last_heard_offset=267),
|
||||
0xA100000A: _build_node(0xA100000A, "Arnold Creek", "ARND", hops=1, last_heard_offset=301),
|
||||
0xA100000B: _build_node(0xA100000B, "Nansen", "NANS", hops=1, last_heard_offset=355),
|
||||
0xA100000C: _build_node(0xA100000C, "Kodin 1", "KOD1", hops=2, last_heard_offset=402),
|
||||
0xA100000D: _build_node(0xA100000D, "PH1", "PH1", hops=3, last_heard_offset=470),
|
||||
0xA100000E: _build_node(0xA100000E, "Luna", "LUNA", hops=1, last_heard_offset=501),
|
||||
0xA100000F: _build_node(0xA100000F, "sputnik1", "SPUT", hops=1, last_heard_offset=550),
|
||||
0xA1000010: _build_node(0xA1000010, "K7EOK Maplewood West", "MAPL", hops=2, last_heard_offset=602),
|
||||
0xA1000011: _build_node(0xA1000011, "KE7YVU 2", "YVU2", hops=2, last_heard_offset=655),
|
||||
0xA1000012: _build_node(0xA1000012, "DNET", "DNET", hops=1, last_heard_offset=702),
|
||||
0xA1000013: _build_node(0xA1000013, "Green Bluff", "GBLF", hops=1, last_heard_offset=780),
|
||||
0xA1000014: _build_node(0xA1000014, "Council Crest Solar", "CCST", hops=2, last_heard_offset=830),
|
||||
0xA1000015: _build_node(0xA1000015, "Meshtastic 61c7", "61c7", hops=1, last_heard_offset=901),
|
||||
0xA1000016: _build_node(0xA1000016, "Bella", "BELA", hops=2, last_heard_offset=950),
|
||||
0xA1000017: _build_node(0xA1000017, "Mojo Solar Base 4f12", "MOJO", hops=1, last_heard_offset=1010, favorite=True),
|
||||
}
|
||||
|
||||
return DemoInterface(nodes=nodes, channels=channels)
|
||||
|
||||
|
||||
def configure_demo_database(base_dir: str = "") -> str:
|
||||
if not base_dir:
|
||||
base_dir = tempfile.mkdtemp(prefix="contact_demo_")
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
|
||||
db_path = os.path.join(base_dir, DEMO_DB_FILENAME)
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
|
||||
config.db_file_path = db_path
|
||||
return db_path
|
||||
|
||||
|
||||
def seed_demo_messages() -> None:
|
||||
schema = """
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
"""
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
cursor = db_connection.cursor()
|
||||
|
||||
for channel_name, rows in _demo_messages().items():
|
||||
table_name = get_table_name(channel_name)
|
||||
cursor.execute(f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})")
|
||||
cursor.executemany(
|
||||
f"""
|
||||
INSERT INTO {table_name} (user_id, message_text, timestamp, ack_type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
|
||||
db_connection.commit()
|
||||
|
||||
|
||||
def _build_node(
|
||||
node_num: int,
|
||||
long_name: str,
|
||||
short_name: str,
|
||||
*,
|
||||
hops: int,
|
||||
last_heard_offset: int,
|
||||
snr: float = 0.0,
|
||||
battery: int = 0,
|
||||
voltage: float = 0.0,
|
||||
favorite: bool = False,
|
||||
ignored: bool = False,
|
||||
) -> Dict[str, object]:
|
||||
node = {
|
||||
"num": node_num,
|
||||
"user": {
|
||||
"longName": long_name,
|
||||
"shortName": short_name,
|
||||
"hwModel": "TBEAM",
|
||||
"role": "CLIENT",
|
||||
"publicKey": f"pk-{node_num:08x}",
|
||||
"isLicensed": True,
|
||||
},
|
||||
"lastHeard": DEMO_BASE_TIMESTAMP + 3600 - last_heard_offset,
|
||||
"hopsAway": hops,
|
||||
"isFavorite": favorite,
|
||||
"isIgnored": ignored,
|
||||
}
|
||||
|
||||
if snr:
|
||||
node["snr"] = snr
|
||||
if battery:
|
||||
node["deviceMetrics"] = {
|
||||
"batteryLevel": battery,
|
||||
"voltage": voltage or 4.0,
|
||||
"uptimeSeconds": 86400 + node_num % 10000,
|
||||
"channelUtilization": 12.5 + (node_num % 7),
|
||||
"airUtilTx": 4.5 + (node_num % 5),
|
||||
}
|
||||
|
||||
if node_num % 3 == 0:
|
||||
node["position"] = {
|
||||
"latitude": 45.5231 + ((node_num % 50) * 0.0001),
|
||||
"longitude": -122.6765 - ((node_num % 50) * 0.0001),
|
||||
"altitude": 85 + (node_num % 20),
|
||||
}
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def _demo_messages() -> Dict[Union[str, int], List[Tuple[str, str, int, Union[str, None]]]]:
|
||||
return {
|
||||
"MediumFast": [
|
||||
(str(DEMO_LOCAL_NODE_NUM), "Help, I'm stuck in a ditch!", DEMO_BASE_TIMESTAMP + 45, "Ack"),
|
||||
("2701131778", "Do you require a alpinist?", DEMO_BASE_TIMESTAMP + 80, None),
|
||||
(str(DEMO_LOCAL_NODE_NUM), "I don't know what that is.", DEMO_BASE_TIMESTAMP + 104, "Implicit"),
|
||||
],
|
||||
"Another Channel": [
|
||||
("2701131788", "Weather is holding for the summit push.", DEMO_BASE_TIMESTAMP + 220, None),
|
||||
(str(DEMO_LOCAL_NODE_NUM), "Copy that. Keep me posted.", DEMO_BASE_TIMESTAMP + 260, "Ack"),
|
||||
],
|
||||
2701131788: [
|
||||
("2701131788", "Ping me when you are back at the trailhead.", DEMO_BASE_TIMESTAMP + 330, None),
|
||||
(str(DEMO_LOCAL_NODE_NUM), "Will do.", DEMO_BASE_TIMESTAMP + 350, "Ack"),
|
||||
],
|
||||
}
|
||||
54
contact/utilities/emoji_utils.py
Normal file
54
contact/utilities/emoji_utils.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Helpers for normalizing emoji sequences in width-sensitive message rendering."""
|
||||
|
||||
# Strip zero-width and presentation modifiers that make terminal cell width inconsistent.
|
||||
EMOJI_MODIFIER_REPLACEMENTS = {
|
||||
"\u200d": "",
|
||||
"\u20e3": "",
|
||||
"\ufe0e": "",
|
||||
"\ufe0f": "",
|
||||
"\U0001F3FB": "",
|
||||
"\U0001F3FC": "",
|
||||
"\U0001F3FD": "",
|
||||
"\U0001F3FE": "",
|
||||
"\U0001F3FF": "",
|
||||
}
|
||||
|
||||
_EMOJI_MODIFIER_TRANSLATION = str.maketrans(EMOJI_MODIFIER_REPLACEMENTS)
|
||||
_REGIONAL_INDICATOR_START = ord("\U0001F1E6")
|
||||
_REGIONAL_INDICATOR_END = ord("\U0001F1FF")
|
||||
|
||||
|
||||
def _regional_indicator_to_letter(char: str) -> str:
|
||||
return chr(ord("A") + ord(char) - _REGIONAL_INDICATOR_START)
|
||||
|
||||
|
||||
def _normalize_flag_emoji(text: str) -> str:
|
||||
"""Convert flag emoji built from regional indicators into ASCII country codes."""
|
||||
normalized = []
|
||||
index = 0
|
||||
|
||||
while index < len(text):
|
||||
current = text[index]
|
||||
current_ord = ord(current)
|
||||
|
||||
if _REGIONAL_INDICATOR_START <= current_ord <= _REGIONAL_INDICATOR_END and index + 1 < len(text):
|
||||
next_char = text[index + 1]
|
||||
next_ord = ord(next_char)
|
||||
if _REGIONAL_INDICATOR_START <= next_ord <= _REGIONAL_INDICATOR_END:
|
||||
normalized.append(_regional_indicator_to_letter(current))
|
||||
normalized.append(_regional_indicator_to_letter(next_char))
|
||||
index += 2
|
||||
continue
|
||||
|
||||
normalized.append(current)
|
||||
index += 1
|
||||
|
||||
return "".join(normalized)
|
||||
|
||||
|
||||
def normalize_message_text(text: str) -> str:
|
||||
"""Strip modifiers and rewrite flag emoji into stable terminal-friendly text."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
return _normalize_flag_emoji(text.translate(_EMOJI_MODIFIER_TRANSLATION))
|
||||
31
contact/utilities/i18n.py
Normal file
31
contact/utilities/i18n.py
Normal file
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
54
contact/utilities/ini_utils.py
Normal file
54
contact/utilities/ini_utils.py
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Optional, List
|
||||
from contact.ui.colors import get_color
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.i18n import t, t_text
|
||||
from contact.utilities.validation_rules import get_validation_for
|
||||
from contact.utilities.singleton import menu_state
|
||||
|
||||
@@ -28,7 +29,7 @@ def invalid_input(window: curses.window, message: str, redraw_func: Optional[cal
|
||||
"""Displays an invalid input message in the given window and redraws if needed."""
|
||||
cursor_y, cursor_x = window.getyx()
|
||||
curses.curs_set(0)
|
||||
dialog("Invalid Input", message)
|
||||
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:
|
||||
@@ -72,6 +73,7 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
|
||||
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
|
||||
@@ -82,7 +84,7 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
|
||||
if row >= height - 3: # Prevent overflow
|
||||
break
|
||||
|
||||
prompt_text = "Enter new value: "
|
||||
prompt_text = t("ui.prompt.enter_new_value", default="Enter new value: ")
|
||||
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||
|
||||
input_win.refresh()
|
||||
@@ -125,41 +127,58 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
|
||||
menu_state.need_redraw = True
|
||||
|
||||
if not user_input.strip():
|
||||
invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win)
|
||||
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, f"Value must be exactly {min_length} characters long.", redraw_func=redraw_input_win
|
||||
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,
|
||||
f"Value must be at least {min_length} characters long.",
|
||||
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,
|
||||
f"Value must be no more than {max_length} characters long.",
|
||||
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, "Only numeric digits (0–9) allowed.", redraw_func=redraw_input_win)
|
||||
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, f"Enter a number between {min_value} and {max_value}.", redraw_func=redraw_input_win
|
||||
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
|
||||
|
||||
@@ -172,12 +191,21 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
|
||||
if not (min_value <= float_val <= max_value):
|
||||
invalid_input(
|
||||
input_win,
|
||||
f"Enter a number between {min_value} and {max_value}.",
|
||||
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, "Must be a valid floating point number.", redraw_func=redraw_input_win)
|
||||
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)
|
||||
@@ -276,13 +304,21 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||
while True:
|
||||
admin_key_win.erase()
|
||||
admin_key_win.border()
|
||||
admin_key_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
|
||||
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),
|
||||
)
|
||||
|
||||
# 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}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
||||
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
|
||||
|
||||
@@ -292,7 +328,7 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||
|
||||
# Show error message if needed
|
||||
if invalid_input:
|
||||
admin_key_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True))
|
||||
admin_key_win.addstr(7, 2, t_text(invalid_input), get_color("settings_default", bold=True))
|
||||
|
||||
admin_key_win.refresh()
|
||||
key = admin_key_win.getch()
|
||||
@@ -312,7 +348,10 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||
curses.curs_set(0)
|
||||
return user_values # Return the edited Base64 values
|
||||
else:
|
||||
invalid_input = "Error: Each key must be valid Base64 and 32 bytes long!"
|
||||
invalid_input = t(
|
||||
"ui.error.admin_key_invalid",
|
||||
default="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
|
||||
@@ -353,13 +392,21 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
||||
def redraw():
|
||||
repeated_win.erase()
|
||||
repeated_win.border()
|
||||
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
|
||||
repeated_win.addstr(
|
||||
1,
|
||||
2,
|
||||
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}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
||||
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
|
||||
|
||||
@@ -415,7 +462,10 @@ from contact.utilities.singleton import menu_state # Ensure this is imported
|
||||
|
||||
def get_fixed32_input(current_value: int) -> int:
|
||||
original_value = current_value
|
||||
ip_string = str(ipaddress.IPv4Address(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))
|
||||
height = 10
|
||||
width = get_dialog_width()
|
||||
start_y = max(0, (curses.LINES - height) // 2)
|
||||
@@ -434,9 +484,21 @@ def get_fixed32_input(current_value: int) -> int:
|
||||
def redraw():
|
||||
fixed32_win.erase()
|
||||
fixed32_win.border()
|
||||
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", get_color("settings_default", bold=True))
|
||||
fixed32_win.addstr(3, 2, f"Current: {ip_string}", get_color("settings_default"))
|
||||
fixed32_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default"))
|
||||
fixed32_win.addstr(
|
||||
1,
|
||||
2,
|
||||
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.refresh()
|
||||
|
||||
while True:
|
||||
@@ -465,14 +527,19 @@ def get_fixed32_input(current_value: int) -> int:
|
||||
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return int(ipaddress.ip_address(user_input))
|
||||
return int.from_bytes(ipaddress.IPv4Address(user_input).packed, "little", signed=False)
|
||||
else:
|
||||
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", get_color("settings_default", bold=True))
|
||||
fixed32_win.addstr(
|
||||
7,
|
||||
2,
|
||||
t("ui.error.ip_invalid", default="Invalid IP address. Try again."),
|
||||
get_color("settings_default", bold=True),
|
||||
)
|
||||
fixed32_win.refresh()
|
||||
curses.napms(1500)
|
||||
user_input = ""
|
||||
|
||||
elif key in (curses.KEY_BACKSPACE, 127):
|
||||
elif key in (curses.KEY_BACKSPACE, curses.KEY_DC, 127, 8, "\b", "\x7f"):
|
||||
user_input = user_input[:-1]
|
||||
|
||||
else:
|
||||
@@ -513,15 +580,17 @@ def get_list_input(
|
||||
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, prompt, get_color("settings_default", bold=True))
|
||||
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))
|
||||
list_pad.addstr(idx, 0, item[:pad_w].ljust(pad_w), color)
|
||||
display_item = t_text(item)
|
||||
list_pad.addstr(idx, 0, display_item[:pad_w].ljust(pad_w), color)
|
||||
|
||||
list_win.refresh()
|
||||
list_pad.refresh(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import time
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
|
||||
|
||||
@@ -41,3 +42,21 @@ def initialize_interface(args):
|
||||
|
||||
except Exception as ex:
|
||||
logging.critical(f"Fatal error initializing interface: {ex}")
|
||||
|
||||
|
||||
def reconnect_interface(args, attempts: int = 15, delay_seconds: float = 1.0):
|
||||
last_error = None
|
||||
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
interface = initialize_interface(args)
|
||||
if interface is not None:
|
||||
return interface
|
||||
last_error = RuntimeError("initialize_interface returned None")
|
||||
except Exception as ex:
|
||||
last_error = ex
|
||||
|
||||
if attempt < attempts - 1:
|
||||
time.sleep(delay_seconds)
|
||||
|
||||
raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error
|
||||
|
||||
@@ -4,6 +4,79 @@ import logging
|
||||
import base64
|
||||
import time
|
||||
|
||||
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
|
||||
POWER_REBOOT_KEYS = {
|
||||
"device_battery_ina_address",
|
||||
"is_power_saving",
|
||||
"ls_secs",
|
||||
"min_wake_secs",
|
||||
"on_battery_shutdown_after_secs",
|
||||
"sds_secs",
|
||||
"wait_bluetooth_secs",
|
||||
}
|
||||
DISPLAY_REBOOT_KEYS = {"screen_on_secs", "flip_screen", "oled", "displaymode"}
|
||||
LORA_REBOOT_KEYS = {
|
||||
"use_preset",
|
||||
"region",
|
||||
"modem_preset",
|
||||
"bandwidth",
|
||||
"spread_factor",
|
||||
"coding_rate",
|
||||
"tx_power",
|
||||
"frequency_offset",
|
||||
"override_frequency",
|
||||
"channel_num",
|
||||
"sx126x_rx_boosted_gain",
|
||||
}
|
||||
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
|
||||
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
|
||||
|
||||
|
||||
def _collect_changed_keys(modified_settings):
|
||||
changed = set()
|
||||
for key, value in modified_settings.items():
|
||||
if isinstance(value, dict):
|
||||
changed.update(_collect_changed_keys(value))
|
||||
else:
|
||||
changed.add(key)
|
||||
return changed
|
||||
|
||||
|
||||
def _requires_reconnect(menu_state, modified_settings) -> bool:
|
||||
if not modified_settings or len(menu_state.menu_path) < 2:
|
||||
return False
|
||||
|
||||
section = menu_state.menu_path[1]
|
||||
changed_keys = _collect_changed_keys(modified_settings)
|
||||
|
||||
if section == "Module Settings":
|
||||
return True
|
||||
if section == "User Settings":
|
||||
return bool(changed_keys & USER_RECONNECT_KEYS)
|
||||
if section == "Channels":
|
||||
return False
|
||||
if section != "Radio Settings" or len(menu_state.menu_path) < 3:
|
||||
return False
|
||||
|
||||
config_category = menu_state.menu_path[2].lower()
|
||||
|
||||
if config_category in {"network", "bluetooth"}:
|
||||
return True
|
||||
if config_category == "security":
|
||||
return not changed_keys.issubset(SECURITY_NON_REBOOT_KEYS)
|
||||
if config_category == "device":
|
||||
return bool(changed_keys & DEVICE_REBOOT_KEYS)
|
||||
if config_category == "power":
|
||||
return bool(changed_keys & POWER_REBOOT_KEYS)
|
||||
if config_category == "display":
|
||||
return bool(changed_keys & DISPLAY_REBOOT_KEYS)
|
||||
if config_category == "lora":
|
||||
return bool(changed_keys & LORA_REBOOT_KEYS)
|
||||
|
||||
# Firmware defaults most config writes to reboot-required unless a handler
|
||||
# explicitly clears that flag.
|
||||
return True
|
||||
|
||||
|
||||
def save_changes(interface, modified_settings, menu_state):
|
||||
"""
|
||||
@@ -15,7 +88,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
try:
|
||||
if not modified_settings:
|
||||
logging.info("No changes to save. modified_settings is empty.")
|
||||
return
|
||||
return False
|
||||
|
||||
node = interface.getNode("^local")
|
||||
admin_key_backup = None
|
||||
@@ -51,7 +124,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
|
||||
# Return early if there are no other settings left to process
|
||||
if not modified_settings:
|
||||
return
|
||||
return _requires_reconnect(menu_state, {"admin_key": admin_key_backup})
|
||||
|
||||
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
|
||||
@@ -63,7 +136,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
||||
return
|
||||
return False
|
||||
|
||||
elif menu_state.menu_path[1] == "User Settings": # for user configs
|
||||
config_category = "User Settings"
|
||||
@@ -78,7 +151,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
|
||||
)
|
||||
|
||||
return
|
||||
return _requires_reconnect(menu_state, modified_settings)
|
||||
|
||||
elif menu_state.menu_path[1] == "Channels": # for channel configs
|
||||
config_category = "Channels"
|
||||
@@ -107,21 +180,28 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
|
||||
logging.info(f"Updated Channel {channel_num} in {config_category}")
|
||||
logging.info(node.channels)
|
||||
return
|
||||
return False
|
||||
|
||||
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 False
|
||||
|
||||
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():
|
||||
# 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
|
||||
config_subcategory = config_container
|
||||
|
||||
# Check if the config_item exists in the subcategory
|
||||
if hasattr(config_subcategory, config_item):
|
||||
@@ -157,8 +237,11 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
|
||||
if admin_key_backup is not None:
|
||||
modified_settings["admin_key"] = admin_key_backup
|
||||
return _requires_reconnect(menu_state, modified_settings)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving changes: {e}")
|
||||
return False
|
||||
|
||||
@@ -68,19 +68,19 @@ def get_chunks(data):
|
||||
# Leave it string as last resort
|
||||
value = value
|
||||
|
||||
match key:
|
||||
# Python 3.9-compatible alternative to match/case.
|
||||
if key == "uptime_seconds":
|
||||
# convert seconds to hours, for our sanity
|
||||
case "uptime_seconds":
|
||||
value = round(value / 60 / 60, 1)
|
||||
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
|
||||
case "longitude_i" | "latitude_i":
|
||||
value = round(value * 1e-7, 6)
|
||||
value = round(value * 1e-7, 6)
|
||||
elif key == "wind_direction":
|
||||
# Convert wind direction from degrees to abbreviation
|
||||
case "wind_direction":
|
||||
value = humanize_wind_direction(value)
|
||||
case "time":
|
||||
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
|
||||
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']} "
|
||||
|
||||
@@ -10,35 +10,50 @@ from contact.utilities.singleton import ui_state, interface_state
|
||||
import contact.utilities.telemetry_beautifier as tb
|
||||
|
||||
|
||||
def _get_channel_name(device_channel, node):
|
||||
if device_channel.settings.name:
|
||||
return device_channel.settings.name
|
||||
|
||||
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
|
||||
return convert_to_camel_case(modem_preset_string)
|
||||
|
||||
|
||||
def get_channels():
|
||||
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
|
||||
"""Retrieve channels from the node and rebuild named channel state."""
|
||||
node = interface_state.interface.getNode("^local")
|
||||
device_channels = node.channels
|
||||
previous_channel_list = list(ui_state.channel_list)
|
||||
previous_messages = dict(ui_state.all_messages)
|
||||
|
||||
# Clear and rebuild channel list
|
||||
# ui_state.channel_list = []
|
||||
named_channels = []
|
||||
|
||||
for device_channel in device_channels:
|
||||
if device_channel.role:
|
||||
# Use the channel name if available, otherwise use the modem preset
|
||||
if device_channel.settings.name:
|
||||
channel_name = device_channel.settings.name
|
||||
else:
|
||||
# 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
|
||||
channel_name = convert_to_camel_case(modem_preset_string)
|
||||
named_channels.append(_get_channel_name(device_channel, node))
|
||||
|
||||
# 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)
|
||||
previous_named_channels = [channel for channel in previous_channel_list if isinstance(channel, str)]
|
||||
preserved_direct_channels = [channel for channel in previous_channel_list if isinstance(channel, int)]
|
||||
rebuilt_messages = {}
|
||||
|
||||
# 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] = []
|
||||
for index, channel_name in enumerate(named_channels):
|
||||
previous_name = previous_named_channels[index] if index < len(previous_named_channels) else channel_name
|
||||
if previous_name in previous_messages:
|
||||
rebuilt_messages[channel_name] = previous_messages[previous_name]
|
||||
elif channel_name in previous_messages:
|
||||
rebuilt_messages[channel_name] = previous_messages[channel_name]
|
||||
else:
|
||||
rebuilt_messages[channel_name] = []
|
||||
|
||||
for channel in preserved_direct_channels:
|
||||
if channel in previous_messages:
|
||||
rebuilt_messages[channel] = previous_messages[channel]
|
||||
|
||||
ui_state.channel_list = named_channels + preserved_direct_channels
|
||||
ui_state.all_messages = rebuilt_messages
|
||||
if ui_state.channel_list:
|
||||
ui_state.selected_channel = max(0, min(ui_state.selected_channel, len(ui_state.channel_list) - 1))
|
||||
|
||||
return ui_state.channel_list
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.4.6"
|
||||
version = "1.5.3"
|
||||
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.14"
|
||||
requires-python = ">=3.9,<3.15"
|
||||
dependencies = [
|
||||
"meshtastic (>=2.6.0,<3.0.0)"
|
||||
"meshtastic (>=2.7.5,<3.0.0)"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
tests/test_arg_parser.py
Normal file
13
tests/test_arg_parser.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import unittest
|
||||
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
|
||||
|
||||
class ArgParserTests(unittest.TestCase):
|
||||
def test_demo_screenshot_flag_is_supported(self) -> None:
|
||||
args = setup_parser().parse_args(["--demo-screenshot"])
|
||||
self.assertTrue(args.demo_screenshot)
|
||||
|
||||
def test_demo_screenshot_defaults_to_false(self) -> None:
|
||||
args = setup_parser().parse_args([])
|
||||
self.assertFalse(args.demo_screenshot)
|
||||
21
tests/test_config_io.py
Normal file
21
tests/test_config_io.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import unittest
|
||||
|
||||
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
|
||||
|
||||
|
||||
class ConfigIoTests(unittest.TestCase):
|
||||
def test_split_compound_name_preserves_multi_part_values(self) -> None:
|
||||
self.assertEqual(splitCompoundName("config.device.role"), ["config", "device", "role"])
|
||||
|
||||
def test_split_compound_name_duplicates_single_part_values(self) -> None:
|
||||
self.assertEqual(splitCompoundName("owner"), ["owner", "owner"])
|
||||
|
||||
def test_is_repeated_field_prefers_new_style_attribute(self) -> None:
|
||||
field = type("Field", (), {"is_repeated": True})()
|
||||
|
||||
self.assertTrue(_is_repeated_field(field))
|
||||
|
||||
def test_is_repeated_field_falls_back_to_label_comparison(self) -> None:
|
||||
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
|
||||
|
||||
self.assertTrue(_is_repeated_field(field_type()))
|
||||
160
tests/test_contact_ui.py
Normal file
160
tests/test_contact_ui.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.ui import contact_ui
|
||||
from contact.utilities.singleton import ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class ContactUiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config("single_pane_mode")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
|
||||
def test_handle_backtick_refreshes_channels_after_settings_menu(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.current_window = 1
|
||||
config.single_pane_mode = "False"
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "curs_set") as curs_set:
|
||||
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
|
||||
with mock.patch.object(contact_ui, "get_channels") as get_channels:
|
||||
with mock.patch.object(contact_ui, "refresh_node_list") as refresh_node_list:
|
||||
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
|
||||
contact_ui.handle_backtick(stdscr)
|
||||
|
||||
settings_menu.assert_called_once()
|
||||
get_channels.assert_called_once_with()
|
||||
refresh_node_list.assert_called_once_with()
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
self.assertEqual(curs_set.call_args_list[0].args, (0,))
|
||||
self.assertEqual(curs_set.call_args_list[-1].args, (1,))
|
||||
self.assertEqual(ui_state.current_window, 1)
|
||||
|
||||
def test_process_pending_ui_updates_draws_requested_windows(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.redraw_channels = True
|
||||
ui_state.redraw_messages = True
|
||||
ui_state.redraw_nodes = True
|
||||
ui_state.redraw_packetlog = True
|
||||
ui_state.scroll_messages_to_bottom = True
|
||||
|
||||
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
|
||||
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
|
||||
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
|
||||
with mock.patch.object(contact_ui, "draw_packetlog_win") as draw_packetlog_win:
|
||||
contact_ui.process_pending_ui_updates(stdscr)
|
||||
|
||||
draw_channel_list.assert_called_once_with()
|
||||
draw_messages_window.assert_called_once_with(True)
|
||||
draw_node_list.assert_called_once_with()
|
||||
draw_packetlog_win.assert_called_once_with()
|
||||
|
||||
def test_process_pending_ui_updates_full_redraw_uses_handle_resize(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.redraw_full_ui = True
|
||||
ui_state.redraw_channels = True
|
||||
ui_state.redraw_messages = True
|
||||
|
||||
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
|
||||
contact_ui.process_pending_ui_updates(stdscr)
|
||||
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
self.assertFalse(ui_state.redraw_channels)
|
||||
self.assertFalse(ui_state.redraw_messages)
|
||||
|
||||
def test_refresh_node_selection_highlights_full_row_width(self) -> None:
|
||||
ui_state.node_list = [101, 202]
|
||||
ui_state.selected_node = 1
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
contact_ui.nodes_pad = mock.Mock()
|
||||
contact_ui.nodes_pad.getmaxyx.return_value = (4, 20)
|
||||
contact_ui.nodes_win = mock.Mock()
|
||||
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
|
||||
|
||||
interface = mock.Mock()
|
||||
interface.nodesByNum = {101: {}, 202: {}}
|
||||
|
||||
with mock.patch.object(contact_ui, "refresh_pad") as refresh_pad:
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
|
||||
with mock.patch.object(contact_ui, "get_node_row_color", side_effect=[11, 22]):
|
||||
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
|
||||
contact_ui.refresh_node_selection(old_index=0, highlight=True)
|
||||
|
||||
self.assertEqual(
|
||||
contact_ui.nodes_pad.chgat.call_args_list,
|
||||
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
|
||||
)
|
||||
refresh_pad.assert_called_once_with(2)
|
||||
draw_window_arrows.assert_called_once_with(2)
|
||||
|
||||
def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
stdscr.getmaxyx.return_value = (24, 80)
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
|
||||
contact_ui.entry_win = mock.Mock()
|
||||
contact_ui.channel_win = mock.Mock()
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.nodes_win = mock.Mock()
|
||||
contact_ui.packetlog_win = mock.Mock()
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.nodes_pad = mock.Mock()
|
||||
contact_ui.channel_pad = mock.Mock()
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
|
||||
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
|
||||
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
|
||||
contact_ui.handle_resize(stdscr, False)
|
||||
|
||||
contact_ui.channel_win.resize.assert_called_once_with(21, 80)
|
||||
contact_ui.messages_win.resize.assert_called_once_with(21, 80)
|
||||
contact_ui.nodes_win.resize.assert_called_once_with(21, 80)
|
||||
contact_ui.channel_win.mvwin.assert_called_once_with(0, 0)
|
||||
contact_ui.messages_win.mvwin.assert_called_once_with(0, 0)
|
||||
contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0)
|
||||
contact_ui.channel_win.box.assert_not_called()
|
||||
contact_ui.nodes_win.box.assert_not_called()
|
||||
contact_ui.messages_win.box.assert_called_once_with()
|
||||
draw_channel_list.assert_called_once_with()
|
||||
draw_messages_window.assert_called_once_with(True)
|
||||
draw_node_list.assert_called_once_with()
|
||||
draw_window_arrows.assert_called_once_with(1)
|
||||
|
||||
def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None:
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
|
||||
self.assertEqual(contact_ui.get_window_title(0), "")
|
||||
self.assertEqual(contact_ui.get_window_title(1), "Primary")
|
||||
|
||||
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
ui_state.display_log = False
|
||||
|
||||
contact_ui.channel_win = mock.Mock()
|
||||
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_pad.getmaxyx.return_value = (5, 20)
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getbegyx.return_value = (0, 0)
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 20)
|
||||
|
||||
with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4):
|
||||
contact_ui.refresh_pad(1)
|
||||
|
||||
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
|
||||
65
tests/test_control_ui.py
Normal file
65
tests/test_control_ui.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from argparse import Namespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.ui import control_ui
|
||||
from contact.utilities.singleton import interface_state
|
||||
|
||||
from tests.test_support import reset_singletons
|
||||
|
||||
|
||||
class ControlUiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_singletons()
|
||||
|
||||
def test_reconnect_interface_with_splash_replaces_interface(self) -> None:
|
||||
old_interface = mock.Mock()
|
||||
new_interface = mock.Mock()
|
||||
stdscr = mock.Mock()
|
||||
parser = mock.Mock()
|
||||
parser.parse_args.return_value = Namespace()
|
||||
|
||||
with mock.patch.object(control_ui, "setup_parser", return_value=parser):
|
||||
with mock.patch.object(control_ui, "draw_splash") as draw_splash:
|
||||
with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||
with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw:
|
||||
result = control_ui.reconnect_interface_with_splash(stdscr, old_interface)
|
||||
|
||||
old_interface.close.assert_called_once_with()
|
||||
stdscr.clear.assert_called_once_with()
|
||||
stdscr.refresh.assert_called_once_with()
|
||||
draw_splash.assert_called_once_with(stdscr)
|
||||
reconnect.assert_called_once_with(parser.parse_args.return_value)
|
||||
redraw.assert_called_once_with(stdscr)
|
||||
self.assertIs(result, new_interface)
|
||||
self.assertIs(interface_state.interface, new_interface)
|
||||
|
||||
def test_reconnect_after_admin_action_runs_action_then_reconnects(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
interface = mock.Mock()
|
||||
new_interface = mock.Mock()
|
||||
action = mock.Mock()
|
||||
|
||||
with mock.patch.object(control_ui, "reconnect_interface_with_splash", return_value=new_interface) as reconnect:
|
||||
result = control_ui.reconnect_after_admin_action(
|
||||
stdscr, interface, action, "Factory Reset Requested by menu"
|
||||
)
|
||||
|
||||
action.assert_called_once_with()
|
||||
reconnect.assert_called_once_with(stdscr, interface)
|
||||
self.assertIs(result, new_interface)
|
||||
|
||||
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
|
||||
with mock.patch("contact.utilities.utils.get_channels") as get_channels:
|
||||
with mock.patch("contact.utilities.utils.refresh_node_list") as refresh_node_list:
|
||||
with mock.patch("contact.ui.contact_ui.handle_resize") as handle_resize:
|
||||
control_ui.redraw_main_ui_after_reconnect(stdscr)
|
||||
|
||||
get_channels.assert_called_once_with()
|
||||
refresh_node_list.assert_called_once_with()
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
15
tests/test_control_utils.py
Normal file
15
tests/test_control_utils.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import unittest
|
||||
|
||||
from contact.utilities.control_utils import transform_menu_path
|
||||
|
||||
|
||||
class ControlUtilsTests(unittest.TestCase):
|
||||
def test_transform_menu_path_applies_replacements_and_normalization(self) -> None:
|
||||
transformed = transform_menu_path(["Main Menu", "Radio Settings", "Channel 2", "Detail"])
|
||||
|
||||
self.assertEqual(transformed, ["config", "channel", "Detail"])
|
||||
|
||||
def test_transform_menu_path_preserves_unmatched_entries(self) -> None:
|
||||
transformed = transform_menu_path(["Main Menu", "Module Settings", "WiFi"])
|
||||
|
||||
self.assertEqual(transformed, ["module", "WiFi"])
|
||||
121
tests/test_db_handler.py
Normal file
121
tests/test_db_handler.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities import db_handler
|
||||
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
from contact.utilities.utils import decimal_to_hex
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class DbHandlerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config(
|
||||
"db_file_path",
|
||||
"message_prefix",
|
||||
"sent_message_prefix",
|
||||
"ack_str",
|
||||
"ack_implicit_str",
|
||||
"ack_unknown_str",
|
||||
"nak_str",
|
||||
)
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
config.db_file_path = os.path.join(self.tempdir.name, "client.db")
|
||||
interface_state.myNodeNum = 123
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tempdir.cleanup()
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
|
||||
def test_save_message_to_db_and_update_ack_roundtrip(self) -> None:
|
||||
timestamp = db_handler.save_message_to_db("Primary", "123", "hello")
|
||||
|
||||
self.assertIsInstance(timestamp, int)
|
||||
|
||||
db_handler.update_ack_nak("Primary", timestamp, "hello", "Ack")
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as conn:
|
||||
row = conn.execute('SELECT user_id, message_text, ack_type FROM "123_Primary_messages"').fetchone()
|
||||
|
||||
self.assertEqual(row, ("123", "hello", "Ack"))
|
||||
|
||||
def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None:
|
||||
db_handler.update_node_info_in_db(999, short_name="ABCD")
|
||||
|
||||
original_long_name = db_handler.get_name_from_database(999, "long")
|
||||
self.assertTrue(original_long_name.startswith("Meshtastic "))
|
||||
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
|
||||
self.assertEqual(db_handler.is_chat_archived(999), 0)
|
||||
|
||||
db_handler.update_node_info_in_db(999, chat_archived=1)
|
||||
|
||||
self.assertEqual(db_handler.get_name_from_database(999, "long"), original_long_name)
|
||||
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
|
||||
self.assertEqual(db_handler.is_chat_archived(999), 1)
|
||||
|
||||
def test_get_name_from_database_returns_hex_when_user_is_missing(self) -> None:
|
||||
user_id = 0x1234ABCD
|
||||
db_handler.ensure_node_table_exists()
|
||||
|
||||
self.assertEqual(db_handler.get_name_from_database(user_id, "short"), decimal_to_hex(user_id))
|
||||
self.assertEqual(db_handler.is_chat_archived(user_id), 0)
|
||||
|
||||
def test_load_messages_from_db_populates_channels_and_messages(self) -> None:
|
||||
db_handler.update_node_info_in_db(123, long_name="Local Node", short_name="ME")
|
||||
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
|
||||
db_handler.update_node_info_in_db(789, long_name="Archived", short_name="AR", chat_archived=1)
|
||||
|
||||
db_handler.ensure_table_exists(
|
||||
'"123_Primary_messages"',
|
||||
"""
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
""",
|
||||
)
|
||||
db_handler.ensure_table_exists(
|
||||
'"123_789_messages"',
|
||||
"""
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
""",
|
||||
)
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as conn:
|
||||
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("123", "sent", 1700000000, "Ack"))
|
||||
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("456", "reply", 1700000001, None))
|
||||
conn.execute('INSERT INTO "123_789_messages" VALUES (?, ?, ?, ?)', ("789", "hidden", 1700000002, None))
|
||||
conn.commit()
|
||||
|
||||
ui_state.channel_list = []
|
||||
ui_state.all_messages = {}
|
||||
|
||||
db_handler.load_messages_from_db()
|
||||
|
||||
self.assertIn("Primary", ui_state.channel_list)
|
||||
self.assertNotIn(789, ui_state.channel_list)
|
||||
self.assertIn("Primary", ui_state.all_messages)
|
||||
self.assertIn(789, ui_state.all_messages)
|
||||
|
||||
messages = ui_state.all_messages["Primary"]
|
||||
self.assertTrue(messages[0][0].startswith("-- "))
|
||||
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in messages))
|
||||
self.assertTrue(any("RM:" in prefix for prefix, _ in messages))
|
||||
self.assertEqual(ui_state.all_messages[789][-1][1], "hidden")
|
||||
|
||||
def test_init_nodedb_inserts_nodes_from_interface(self) -> None:
|
||||
interface_state.interface = build_demo_interface()
|
||||
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
|
||||
|
||||
db_handler.init_nodedb()
|
||||
|
||||
self.assertEqual(db_handler.get_name_from_database(2701131778, "short"), "SAT2")
|
||||
38
tests/test_default_config.py
Normal file
38
tests/test_default_config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from contact.ui import default_config
|
||||
|
||||
|
||||
class DefaultConfigTests(unittest.TestCase):
|
||||
def test_get_localisation_options_filters_hidden_and_non_ini_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for filename in ("en.ini", "ru.ini", ".hidden.ini", "notes.txt"):
|
||||
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
|
||||
handle.write("")
|
||||
|
||||
self.assertEqual(default_config.get_localisation_options(tmpdir), ["en", "ru"])
|
||||
|
||||
def test_get_localisation_file_normalizes_extensions_and_falls_back_to_english(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for filename in ("en.ini", "ru.ini"):
|
||||
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
|
||||
handle.write("")
|
||||
|
||||
self.assertTrue(default_config.get_localisation_file("RU.ini", tmpdir).endswith("/ru.ini"))
|
||||
self.assertTrue(default_config.get_localisation_file("missing", tmpdir).endswith("/en.ini"))
|
||||
|
||||
def test_update_dict_only_adds_missing_values(self) -> None:
|
||||
default = {"theme": "dark", "nested": {"language": "en", "sound": True}}
|
||||
actual = {"nested": {"language": "ru"}}
|
||||
|
||||
updated = default_config.update_dict(default, actual)
|
||||
|
||||
self.assertTrue(updated)
|
||||
self.assertEqual(actual, {"theme": "dark", "nested": {"language": "ru", "sound": True}})
|
||||
|
||||
def test_format_json_single_line_arrays_keeps_arrays_inline(self) -> None:
|
||||
rendered = default_config.format_json_single_line_arrays({"items": [1, 2], "nested": {"flags": ["a", "b"]}})
|
||||
|
||||
self.assertIn('"items": [1, 2]', rendered)
|
||||
self.assertIn('"flags": ["a", "b"]', rendered)
|
||||
51
tests/test_demo_data.py
Normal file
51
tests/test_demo_data.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.__main__ as entrypoint
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.db_handler import get_name_from_database
|
||||
from contact.utilities.demo_data import DEMO_CHANNELS, DEMO_LOCAL_NODE_NUM, build_demo_interface, configure_demo_database
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class DemoDataTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config("db_file_path", "node_sort", "single_pane_mode")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
|
||||
def test_build_demo_interface_exposes_expected_shape(self) -> None:
|
||||
interface = build_demo_interface()
|
||||
|
||||
self.assertEqual(interface.getMyNodeInfo()["num"], DEMO_LOCAL_NODE_NUM)
|
||||
self.assertEqual([channel.settings.name for channel in interface.getNode("^local").channels], DEMO_CHANNELS)
|
||||
self.assertIn(DEMO_LOCAL_NODE_NUM, interface.nodesByNum)
|
||||
|
||||
def test_initialize_globals_seed_demo_populates_ui_state_and_db(self) -> None:
|
||||
interface_state.interface = build_demo_interface()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
demo_db_path = configure_demo_database(tmpdir)
|
||||
with mock.patch.object(entrypoint.pub, "subscribe"):
|
||||
entrypoint.initialize_globals(seed_demo=True)
|
||||
|
||||
self.assertEqual(config.db_file_path, demo_db_path)
|
||||
self.assertIn("MediumFast", ui_state.channel_list)
|
||||
self.assertIn("Another Channel", ui_state.channel_list)
|
||||
self.assertIn(2701131788, ui_state.channel_list)
|
||||
self.assertEqual(ui_state.node_list[0], DEMO_LOCAL_NODE_NUM)
|
||||
self.assertEqual(get_name_from_database(2701131778, "short"), "SAT2")
|
||||
|
||||
medium_fast = ui_state.all_messages["MediumFast"]
|
||||
self.assertTrue(medium_fast[0][0].startswith("-- "))
|
||||
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in medium_fast))
|
||||
self.assertTrue(any("SAT2:" in prefix for prefix, _ in medium_fast))
|
||||
|
||||
direct_messages = ui_state.all_messages[2701131788]
|
||||
self.assertEqual(len(direct_messages), 3)
|
||||
11
tests/test_emoji_utils.py
Normal file
11
tests/test_emoji_utils.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import unittest
|
||||
|
||||
from contact.utilities.emoji_utils import normalize_message_text
|
||||
|
||||
|
||||
class EmojiUtilsTests(unittest.TestCase):
|
||||
def test_strips_modifiers_from_keycaps_and_skin_tones(self) -> None:
|
||||
self.assertEqual(normalize_message_text("👍🏽 7️⃣"), "👍 7")
|
||||
|
||||
def test_rewrites_flag_emoji_to_country_codes(self) -> None:
|
||||
self.assertEqual(normalize_message_text("🇺🇸 hello 🇩🇪"), "US hello DE")
|
||||
57
tests/test_i18n.py
Normal file
57
tests/test_i18n.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities import i18n
|
||||
|
||||
from tests.test_support import restore_config, snapshot_config
|
||||
|
||||
|
||||
class I18nTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.saved_config = snapshot_config("language")
|
||||
i18n._translations = {}
|
||||
i18n._language = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
i18n._translations = {}
|
||||
i18n._language = None
|
||||
|
||||
def test_t_loads_translation_file_and_formats_placeholders(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
translation_file = os.path.join(tmpdir, "xx.ini")
|
||||
with open(translation_file, "w", encoding="utf-8") as handle:
|
||||
handle.write('[ui]\n')
|
||||
handle.write('greeting,"Hello {name}"\n')
|
||||
|
||||
config.language = "xx"
|
||||
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
|
||||
self.assertEqual(i18n.t("ui.greeting", name="Ben"), "Hello Ben")
|
||||
|
||||
def test_t_falls_back_to_default_and_returns_unformatted_text_on_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
translation_file = os.path.join(tmpdir, "xx.ini")
|
||||
with open(translation_file, "w", encoding="utf-8") as handle:
|
||||
handle.write('[ui]\n')
|
||||
handle.write('greeting,"Hello {name}"\n')
|
||||
|
||||
config.language = "xx"
|
||||
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
|
||||
self.assertEqual(i18n.t("ui.greeting"), "Hello {name}")
|
||||
self.assertEqual(i18n.t("ui.missing", default="Fallback"), "Fallback")
|
||||
self.assertEqual(i18n.t_text("Literal {value}", value=7), "Literal 7")
|
||||
|
||||
def test_loader_cache_is_reused_until_language_changes(self) -> None:
|
||||
config.language = "en"
|
||||
|
||||
with mock.patch.object(i18n, "parse_ini_file", return_value=({"key": "value"}, {})) as parse_ini_file:
|
||||
self.assertEqual(i18n.t("key"), "value")
|
||||
self.assertEqual(i18n.t("key"), "value")
|
||||
self.assertEqual(parse_ini_file.call_count, 1)
|
||||
|
||||
config.language = "ru"
|
||||
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
|
||||
self.assertEqual(parse_ini_file.call_count, 2)
|
||||
40
tests/test_ini_utils.py
Normal file
40
tests/test_ini_utils.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.utilities.ini_utils import parse_ini_file
|
||||
|
||||
|
||||
class IniUtilsTests(unittest.TestCase):
|
||||
def test_parse_ini_file_reads_sections_fields_and_help_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ini_path = os.path.join(tmpdir, "settings.ini")
|
||||
with open(ini_path, "w", encoding="utf-8") as handle:
|
||||
handle.write('; comment\n')
|
||||
handle.write('[config.device]\n')
|
||||
handle.write('title,"Device","Device help"\n')
|
||||
handle.write('name,"Node Name","Node help"\n')
|
||||
handle.write('empty_help,"Fallback",""\n')
|
||||
|
||||
with mock.patch("contact.utilities.ini_utils.i18n.t", return_value="No help available."):
|
||||
mapping, help_text = parse_ini_file(ini_path)
|
||||
|
||||
self.assertEqual(mapping["config.device"], "Device")
|
||||
self.assertEqual(help_text["config.device"], "Device help")
|
||||
self.assertEqual(mapping["config.device.name"], "Node Name")
|
||||
self.assertEqual(help_text["config.device.name"], "Node help")
|
||||
self.assertEqual(help_text["config.device.empty_help"], "No help available.")
|
||||
|
||||
def test_parse_ini_file_uses_builtin_help_fallback_when_i18n_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ini_path = os.path.join(tmpdir, "settings.ini")
|
||||
with open(ini_path, "w", encoding="utf-8") as handle:
|
||||
handle.write('[section]\n')
|
||||
handle.write('name,"Name"\n')
|
||||
|
||||
with mock.patch("contact.utilities.ini_utils.i18n.t", side_effect=RuntimeError("boom")):
|
||||
mapping, help_text = parse_ini_file(ini_path)
|
||||
|
||||
self.assertEqual(mapping["section.name"], "Name")
|
||||
self.assertEqual(help_text["section.name"], "No help available.")
|
||||
26
tests/test_interfaces.py
Normal file
26
tests/test_interfaces.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from argparse import Namespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.utilities.interfaces import reconnect_interface
|
||||
|
||||
|
||||
class InterfacesTests(unittest.TestCase):
|
||||
def test_reconnect_interface_retries_until_connection_succeeds(self) -> None:
|
||||
args = Namespace()
|
||||
|
||||
with mock.patch("contact.utilities.interfaces.initialize_interface", side_effect=[None, None, "iface"]) as initialize:
|
||||
with mock.patch("contact.utilities.interfaces.time.sleep") as sleep:
|
||||
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
|
||||
|
||||
self.assertEqual(result, "iface")
|
||||
self.assertEqual(initialize.call_count, 3)
|
||||
self.assertEqual(sleep.call_count, 2)
|
||||
|
||||
def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None:
|
||||
args = Namespace()
|
||||
|
||||
with mock.patch("contact.utilities.interfaces.initialize_interface", return_value=None):
|
||||
with mock.patch("contact.utilities.interfaces.time.sleep"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
reconnect_interface(args, attempts=2, delay_seconds=0)
|
||||
232
tests/test_main.py
Normal file
232
tests/test_main.py
Normal file
@@ -0,0 +1,232 @@
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.__main__ as entrypoint
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class MainRuntimeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config("single_pane_mode")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
|
||||
def test_initialize_runtime_interface_uses_demo_branch(self) -> None:
|
||||
args = Namespace(demo_screenshot=True)
|
||||
|
||||
with mock.patch.object(entrypoint, "configure_demo_database") as configure_demo_database:
|
||||
with mock.patch.object(entrypoint, "build_demo_interface", return_value="demo-interface") as build_demo:
|
||||
with mock.patch.object(entrypoint, "initialize_interface") as initialize_interface:
|
||||
result = entrypoint.initialize_runtime_interface(args)
|
||||
|
||||
self.assertEqual(result, "demo-interface")
|
||||
configure_demo_database.assert_called_once_with()
|
||||
build_demo.assert_called_once_with()
|
||||
initialize_interface.assert_not_called()
|
||||
|
||||
def test_initialize_runtime_interface_uses_live_branch_without_demo_flag(self) -> None:
|
||||
args = Namespace(demo_screenshot=False)
|
||||
|
||||
with mock.patch.object(entrypoint, "initialize_interface", return_value="live-interface") as initialize_interface:
|
||||
result = entrypoint.initialize_runtime_interface(args)
|
||||
|
||||
self.assertEqual(result, "live-interface")
|
||||
initialize_interface.assert_called_once_with(args)
|
||||
|
||||
def test_interface_is_ready_detects_missing_local_node(self) -> None:
|
||||
self.assertFalse(entrypoint.interface_is_ready(object()))
|
||||
self.assertTrue(entrypoint.interface_is_ready(SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))))
|
||||
|
||||
def test_initialize_runtime_interface_with_retry_retries_until_node_is_ready(self) -> None:
|
||||
args = Namespace(demo_screenshot=False)
|
||||
stdscr = mock.Mock()
|
||||
bad_interface = mock.Mock(spec=["close"])
|
||||
good_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))
|
||||
|
||||
with mock.patch.object(entrypoint, "initialize_runtime_interface", side_effect=[bad_interface, good_interface]):
|
||||
with mock.patch.object(entrypoint, "get_list_input", return_value="Retry") as get_list_input:
|
||||
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
|
||||
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
|
||||
|
||||
self.assertIs(result, good_interface)
|
||||
get_list_input.assert_called_once()
|
||||
bad_interface.close.assert_called_once_with()
|
||||
draw_splash.assert_called_once_with(stdscr)
|
||||
|
||||
def test_initialize_runtime_interface_with_retry_returns_none_when_user_closes(self) -> None:
|
||||
args = Namespace(demo_screenshot=False)
|
||||
stdscr = mock.Mock()
|
||||
bad_interface = mock.Mock(spec=["close"])
|
||||
|
||||
with mock.patch.object(entrypoint, "initialize_runtime_interface", return_value=bad_interface):
|
||||
with mock.patch.object(entrypoint, "get_list_input", return_value="Close") as get_list_input:
|
||||
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
|
||||
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
|
||||
|
||||
self.assertIsNone(result)
|
||||
get_list_input.assert_called_once()
|
||||
bad_interface.close.assert_called_once_with()
|
||||
draw_splash.assert_not_called()
|
||||
|
||||
def test_prompt_region_if_unset_reinitializes_interface_after_confirmation(self) -> None:
|
||||
args = Namespace()
|
||||
old_interface = mock.Mock()
|
||||
new_interface = mock.Mock()
|
||||
stdscr = mock.Mock()
|
||||
interface_state.interface = old_interface
|
||||
|
||||
with mock.patch.object(entrypoint, "get_list_input", return_value="Yes"):
|
||||
with mock.patch.object(entrypoint, "set_region") as set_region:
|
||||
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
|
||||
with mock.patch.object(entrypoint, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||
entrypoint.prompt_region_if_unset(args, stdscr)
|
||||
|
||||
set_region.assert_called_once_with(old_interface)
|
||||
old_interface.close.assert_called_once_with()
|
||||
draw_splash.assert_called_once_with(stdscr)
|
||||
reconnect.assert_called_once_with(args)
|
||||
self.assertIs(interface_state.interface, new_interface)
|
||||
|
||||
def test_prompt_region_if_unset_leaves_interface_unchanged_when_declined(self) -> None:
|
||||
args = Namespace()
|
||||
interface = mock.Mock()
|
||||
interface_state.interface = interface
|
||||
|
||||
with mock.patch.object(entrypoint, "get_list_input", return_value="No"):
|
||||
with mock.patch.object(entrypoint, "set_region") as set_region:
|
||||
with mock.patch.object(entrypoint, "reconnect_interface") as reconnect:
|
||||
entrypoint.prompt_region_if_unset(args)
|
||||
|
||||
set_region.assert_not_called()
|
||||
reconnect.assert_not_called()
|
||||
interface.close.assert_not_called()
|
||||
self.assertIs(interface_state.interface, interface)
|
||||
|
||||
def test_initialize_globals_resets_and_populates_runtime_state(self) -> None:
|
||||
ui_state.channel_list = ["stale"]
|
||||
ui_state.all_messages = {"stale": [("old", "message")]}
|
||||
ui_state.notifications = [1]
|
||||
ui_state.packet_buffer = ["packet"]
|
||||
ui_state.node_list = [99]
|
||||
ui_state.selected_channel = 3
|
||||
ui_state.selected_message = 4
|
||||
ui_state.selected_node = 5
|
||||
ui_state.start_index = [9, 9, 9]
|
||||
config.single_pane_mode = "True"
|
||||
|
||||
with mock.patch.object(entrypoint, "get_nodeNum", return_value=123):
|
||||
with mock.patch.object(entrypoint, "get_channels", return_value=["Primary"]) as get_channels:
|
||||
with mock.patch.object(entrypoint, "get_node_list", return_value=[123, 456]) as get_node_list:
|
||||
with mock.patch.object(entrypoint.pub, "subscribe") as subscribe:
|
||||
with mock.patch.object(entrypoint, "init_nodedb") as init_nodedb:
|
||||
with mock.patch.object(entrypoint, "seed_demo_messages") as seed_demo_messages:
|
||||
with mock.patch.object(entrypoint, "load_messages_from_db") as load_messages:
|
||||
entrypoint.initialize_globals(seed_demo=True)
|
||||
|
||||
self.assertEqual(ui_state.channel_list, ["Primary"])
|
||||
self.assertEqual(ui_state.all_messages, {})
|
||||
self.assertEqual(ui_state.notifications, [])
|
||||
self.assertEqual(ui_state.packet_buffer, [])
|
||||
self.assertEqual(ui_state.node_list, [123, 456])
|
||||
self.assertEqual(ui_state.selected_channel, 0)
|
||||
self.assertEqual(ui_state.selected_message, 0)
|
||||
self.assertEqual(ui_state.selected_node, 0)
|
||||
self.assertEqual(ui_state.start_index, [0, 0, 0])
|
||||
self.assertTrue(ui_state.single_pane_mode)
|
||||
self.assertEqual(interface_state.myNodeNum, 123)
|
||||
get_channels.assert_called_once_with()
|
||||
get_node_list.assert_called_once_with()
|
||||
subscribe.assert_called_once_with(entrypoint.on_receive, "meshtastic.receive")
|
||||
init_nodedb.assert_called_once_with()
|
||||
seed_demo_messages.assert_called_once_with()
|
||||
load_messages.assert_called_once_with()
|
||||
|
||||
def test_ensure_min_rows_retries_until_terminal_is_large_enough(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
stdscr.getmaxyx.side_effect = [(10, 80), (11, 80)]
|
||||
|
||||
with mock.patch.object(entrypoint, "dialog") as dialog:
|
||||
with mock.patch.object(entrypoint.curses, "update_lines_cols") as update_lines_cols:
|
||||
entrypoint.ensure_min_rows(stdscr, min_rows=11)
|
||||
|
||||
dialog.assert_called_once()
|
||||
update_lines_cols.assert_called_once_with()
|
||||
stdscr.clear.assert_called_once_with()
|
||||
stdscr.refresh.assert_called_once_with()
|
||||
|
||||
def test_start_prints_help_and_exits_zero(self) -> None:
|
||||
parser = mock.Mock()
|
||||
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact", "--help"]):
|
||||
with mock.patch.object(entrypoint, "setup_parser", return_value=parser):
|
||||
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
entrypoint.start()
|
||||
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
parser.print_help.assert_called_once_with()
|
||||
exit_mock.assert_called_once_with(0)
|
||||
|
||||
def test_start_runs_curses_wrapper_and_closes_interface(self) -> None:
|
||||
interface = mock.Mock()
|
||||
interface_state.interface = interface
|
||||
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
|
||||
entrypoint.start()
|
||||
|
||||
wrapper.assert_called_once_with(entrypoint.main)
|
||||
interface.close.assert_called_once_with()
|
||||
|
||||
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
args = Namespace(settings=False, demo_screenshot=False)
|
||||
|
||||
with mock.patch.object(entrypoint, "setup_colors"):
|
||||
with mock.patch.object(entrypoint, "ensure_min_rows"):
|
||||
with mock.patch.object(entrypoint, "draw_splash"):
|
||||
with mock.patch.object(entrypoint, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(entrypoint, "initialize_runtime_interface_with_retry", return_value=None):
|
||||
with mock.patch.object(entrypoint, "initialize_globals") as initialize_globals:
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
entrypoint.main(stdscr)
|
||||
|
||||
initialize_globals.assert_not_called()
|
||||
|
||||
def test_start_handles_keyboard_interrupt(self) -> None:
|
||||
interface = mock.Mock()
|
||||
interface_state.interface = interface
|
||||
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
|
||||
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
entrypoint.start()
|
||||
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
interface.close.assert_called_once_with()
|
||||
exit_mock.assert_called_once_with(0)
|
||||
|
||||
def test_start_handles_fatal_exception_and_exits_one(self) -> None:
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")):
|
||||
with mock.patch.object(entrypoint.curses, "endwin") as endwin:
|
||||
with mock.patch.object(entrypoint.traceback, "print_exc") as print_exc:
|
||||
with mock.patch("builtins.print") as print_mock:
|
||||
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(1)) as exit_mock:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
entrypoint.start()
|
||||
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
endwin.assert_called_once_with()
|
||||
print_exc.assert_called_once_with()
|
||||
print_mock.assert_any_call("Fatal error:", mock.ANY)
|
||||
exit_mock.assert_called_once_with(1)
|
||||
36
tests/test_nav_utils.py
Normal file
36
tests/test_nav_utils.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.ui import nav_utils
|
||||
from contact.ui.nav_utils import truncate_with_ellipsis, wrap_text
|
||||
from contact.utilities.singleton import ui_state
|
||||
|
||||
|
||||
class NavUtilsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
ui_state.current_window = 0
|
||||
ui_state.node_list = []
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
|
||||
def test_wrap_text_splits_wide_characters_by_display_width(self) -> None:
|
||||
self.assertEqual(wrap_text("🔐🔐🔐", 4), ["🔐", "🔐", "🔐"])
|
||||
|
||||
def test_truncate_with_ellipsis_respects_display_width(self) -> None:
|
||||
self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…")
|
||||
|
||||
def test_highlight_line_uses_full_node_row_width(self) -> None:
|
||||
ui_state.current_window = 2
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
menu_win = mock.Mock()
|
||||
menu_win.getbegyx.return_value = (0, 0)
|
||||
menu_win.getmaxyx.return_value = (8, 20)
|
||||
menu_pad = mock.Mock()
|
||||
menu_pad.getmaxyx.return_value = (4, 20)
|
||||
|
||||
with mock.patch.object(nav_utils, "get_node_color", side_effect=[11, 22]):
|
||||
nav_utils.highlight_line(menu_win, menu_pad, 0, 1, 5)
|
||||
|
||||
self.assertEqual(
|
||||
menu_pad.chgat.call_args_list,
|
||||
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
|
||||
)
|
||||
90
tests/test_rx_handler.py
Normal file
90
tests/test_rx_handler.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.message_handlers import rx_handler
|
||||
from contact.utilities.singleton import interface_state, menu_state, ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class RxHandlerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config("notification_sound", "message_prefix")
|
||||
config.notification_sound = "False"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
|
||||
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": []}
|
||||
ui_state.selected_channel = 0
|
||||
|
||||
packet = {
|
||||
"from": 222,
|
||||
"to": 999,
|
||||
"channel": 0,
|
||||
"hopStart": 3,
|
||||
"hopLimit": 1,
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
|
||||
}
|
||||
|
||||
with mock.patch.object(rx_handler, "refresh_node_list", return_value=True):
|
||||
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
|
||||
with mock.patch.object(rx_handler, "add_notification") as add_notification:
|
||||
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
|
||||
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
|
||||
rx_handler.on_receive(packet, interface=None)
|
||||
|
||||
self.assertEqual(request_ui_redraw.call_args_list, [mock.call(nodes=True), mock.call(messages=True, scroll_messages_to_bottom=True)])
|
||||
add_notification.assert_not_called()
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hello")
|
||||
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
|
||||
self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0])
|
||||
self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0])
|
||||
|
||||
def test_on_receive_direct_message_adds_channel_and_notification(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": []}
|
||||
ui_state.selected_channel = 0
|
||||
|
||||
packet = {
|
||||
"from": 222,
|
||||
"to": 111,
|
||||
"hopStart": 1,
|
||||
"hopLimit": 1,
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"dm"},
|
||||
}
|
||||
|
||||
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
|
||||
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
|
||||
with mock.patch.object(rx_handler, "add_notification") as add_notification:
|
||||
with mock.patch.object(rx_handler, "update_node_info_in_db") as update_node_info_in_db:
|
||||
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
|
||||
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
|
||||
rx_handler.on_receive(packet, interface=None)
|
||||
|
||||
self.assertIn(222, ui_state.channel_list)
|
||||
self.assertIn(222, ui_state.all_messages)
|
||||
request_ui_redraw.assert_called_once_with(channels=True)
|
||||
add_notification.assert_called_once_with(1)
|
||||
update_node_info_in_db.assert_called_once_with(222, chat_archived=False)
|
||||
save_message_to_db.assert_called_once_with(222, 222, "dm")
|
||||
|
||||
def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None:
|
||||
ui_state.packet_buffer = list(range(25))
|
||||
ui_state.display_log = True
|
||||
ui_state.current_window = 4
|
||||
|
||||
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
|
||||
rx_handler.on_receive({"id": "new"}, interface=None)
|
||||
|
||||
request_ui_redraw.assert_called_once_with(packetlog=True)
|
||||
self.assertEqual(len(ui_state.packet_buffer), 20)
|
||||
self.assertEqual(ui_state.packet_buffer[-1], {"id": "new"})
|
||||
self.assertTrue(menu_state.need_redraw)
|
||||
114
tests/test_save_to_radio.py
Normal file
114
tests/test_save_to_radio.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.utilities.save_to_radio import save_changes
|
||||
|
||||
|
||||
class SaveToRadioTests(unittest.TestCase):
|
||||
def build_interface(self):
|
||||
node = mock.Mock()
|
||||
node.localConfig = SimpleNamespace(
|
||||
lora=SimpleNamespace(region=0, serial_enabled=False),
|
||||
device=SimpleNamespace(role="CLIENT", name="node"),
|
||||
security=SimpleNamespace(debug_log_api_enabled=False, serial_enabled=False, admin_key=[]),
|
||||
display=SimpleNamespace(flip_screen=False, units=0),
|
||||
power=SimpleNamespace(is_power_saving=False, adc_enabled=False),
|
||||
network=SimpleNamespace(wifi_enabled=False),
|
||||
bluetooth=SimpleNamespace(enabled=False),
|
||||
)
|
||||
node.moduleConfig = SimpleNamespace(mqtt=SimpleNamespace(enabled=False))
|
||||
interface = mock.Mock()
|
||||
interface.getNode.return_value = node
|
||||
return interface, node
|
||||
|
||||
def test_save_changes_returns_true_for_lora_writes_that_require_reconnect(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"region": 7}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertEqual(node.localConfig.lora.region, 7)
|
||||
node.writeConfig.assert_called_once_with("lora")
|
||||
|
||||
def test_save_changes_returns_false_when_nothing_changed(self) -> None:
|
||||
interface = mock.Mock()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
|
||||
|
||||
self.assertFalse(save_changes(interface, {}, menu_state))
|
||||
|
||||
def test_save_changes_returns_false_for_non_rebooting_security_fields(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"serial_enabled": True}, menu_state)
|
||||
|
||||
self.assertFalse(reconnect_required)
|
||||
self.assertTrue(node.localConfig.security.serial_enabled)
|
||||
|
||||
def test_save_changes_returns_true_for_rebooting_security_fields(self) -> None:
|
||||
interface, _node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"admin_key": [b"12345678"]}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
|
||||
def test_save_changes_returns_true_only_for_rebooting_device_fields(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Device"])
|
||||
|
||||
self.assertFalse(save_changes(interface, {"name": "renamed"}, menu_state))
|
||||
self.assertEqual(node.localConfig.device.name, "renamed")
|
||||
|
||||
node.writeConfig.reset_mock()
|
||||
self.assertTrue(save_changes(interface, {"role": "ROUTER"}, menu_state))
|
||||
self.assertEqual(node.localConfig.device.role, "ROUTER")
|
||||
|
||||
def test_save_changes_returns_true_for_network_settings(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Network"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"wifi_enabled": True}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertTrue(node.localConfig.network.wifi_enabled)
|
||||
|
||||
def test_save_changes_returns_true_only_for_rebooting_power_fields(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Power"])
|
||||
|
||||
self.assertFalse(save_changes(interface, {"adc_enabled": True}, menu_state))
|
||||
self.assertTrue(node.localConfig.power.adc_enabled)
|
||||
|
||||
node.writeConfig.reset_mock()
|
||||
self.assertTrue(save_changes(interface, {"is_power_saving": True}, menu_state))
|
||||
self.assertTrue(node.localConfig.power.is_power_saving)
|
||||
|
||||
def test_save_changes_returns_true_for_module_settings(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Module Settings", "Mqtt"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"enabled": True}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertTrue(node.moduleConfig.mqtt.enabled)
|
||||
|
||||
def test_save_changes_returns_true_for_user_name_changes(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"longName": "Node"}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
node.setOwner.assert_called_once()
|
||||
|
||||
def test_save_changes_returns_true_for_user_license_changes(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"isLicensed": True}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
node.setOwner.assert_called_once()
|
||||
27
tests/test_support.py
Normal file
27
tests/test_support.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import threading
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.ui.ui_state import AppState, ChatUIState, InterfaceState, MenuState
|
||||
from contact.utilities.singleton import app_state, interface_state, menu_state, ui_state
|
||||
|
||||
|
||||
def reset_singletons() -> None:
|
||||
_reset_instance(ui_state, ChatUIState())
|
||||
_reset_instance(interface_state, InterfaceState())
|
||||
_reset_instance(menu_state, MenuState())
|
||||
_reset_instance(app_state, AppState())
|
||||
app_state.lock = threading.Lock()
|
||||
|
||||
|
||||
def restore_config(saved: dict) -> None:
|
||||
for key, value in saved.items():
|
||||
setattr(config, key, value)
|
||||
|
||||
|
||||
def snapshot_config(*keys: str) -> dict:
|
||||
return {key: getattr(config, key) for key in keys}
|
||||
|
||||
|
||||
def _reset_instance(target: object, replacement: object) -> None:
|
||||
target.__dict__.clear()
|
||||
target.__dict__.update(replacement.__dict__)
|
||||
27
tests/test_telemetry_beautifier.py
Normal file
27
tests/test_telemetry_beautifier.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.utilities.telemetry_beautifier import get_chunks, humanize_wind_direction
|
||||
|
||||
|
||||
class TelemetryBeautifierTests(unittest.TestCase):
|
||||
def test_humanize_wind_direction_handles_boundaries(self) -> None:
|
||||
self.assertEqual(humanize_wind_direction(0), "N")
|
||||
self.assertEqual(humanize_wind_direction(90), "E")
|
||||
self.assertEqual(humanize_wind_direction(225), "SW")
|
||||
self.assertIsNone(humanize_wind_direction(-1))
|
||||
|
||||
def test_get_chunks_formats_known_and_unknown_values(self) -> None:
|
||||
rendered = get_chunks("uptime_seconds:7200\nwind_direction:90\nlatitude_i:123456789\nunknown:abc\n")
|
||||
|
||||
self.assertIn("🆙 2.0h", rendered)
|
||||
self.assertIn("⮆ E", rendered)
|
||||
self.assertIn("🌍 12.345679", rendered)
|
||||
self.assertIn("unknown:abc", rendered)
|
||||
|
||||
def test_get_chunks_formats_time_values(self) -> None:
|
||||
with mock.patch("contact.utilities.telemetry_beautifier.datetime.datetime") as mocked_datetime:
|
||||
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "01.01.1970 00:00"
|
||||
rendered = get_chunks("time:0\n")
|
||||
|
||||
self.assertIn("🕔 01.01.1970 00:00", rendered)
|
||||
107
tests/test_tx_handler.py
Normal file
107
tests/test_tx_handler.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from meshtastic import BROADCAST_NUM
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.message_handlers import tx_handler
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class TxHandlerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
tx_handler.ack_naks.clear()
|
||||
self.saved_config = snapshot_config("sent_message_prefix", "ack_str", "ack_implicit_str", "nak_str", "ack_unknown_str")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
tx_handler.ack_naks.clear()
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
|
||||
def test_send_message_on_named_channel_tracks_ack_request(self) -> None:
|
||||
interface = mock.Mock()
|
||||
interface.sendText.return_value = SimpleNamespace(id="req-1")
|
||||
interface_state.interface = interface
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": []}
|
||||
|
||||
with mock.patch.object(tx_handler, "save_message_to_db", return_value=999) as save_message_to_db:
|
||||
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
|
||||
tx_handler.send_message("hello", channel=0)
|
||||
|
||||
interface.sendText.assert_called_once_with(
|
||||
text="hello",
|
||||
destinationId=BROADCAST_NUM,
|
||||
wantAck=True,
|
||||
wantResponse=False,
|
||||
onResponse=tx_handler.onAckNak,
|
||||
channelIndex=0,
|
||||
)
|
||||
save_message_to_db.assert_called_once_with("Primary", 111, "hello")
|
||||
self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary")
|
||||
self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1)
|
||||
self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999)
|
||||
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
|
||||
|
||||
def test_send_message_to_direct_node_uses_node_as_destination(self) -> None:
|
||||
interface = mock.Mock()
|
||||
interface.sendText.return_value = SimpleNamespace(id="req-2")
|
||||
interface_state.interface = interface
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = [222]
|
||||
ui_state.all_messages = {222: []}
|
||||
|
||||
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
|
||||
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
|
||||
tx_handler.send_message("dm", channel=0)
|
||||
|
||||
interface.sendText.assert_called_once_with(
|
||||
text="dm",
|
||||
destinationId=222,
|
||||
wantAck=True,
|
||||
wantResponse=False,
|
||||
onResponse=tx_handler.onAckNak,
|
||||
channelIndex=0,
|
||||
)
|
||||
self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222)
|
||||
|
||||
def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.all_messages = {"Primary": [("pending", "hello")]}
|
||||
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
|
||||
|
||||
packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
|
||||
|
||||
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
|
||||
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
|
||||
with mock.patch("contact.ui.contact_ui.request_ui_redraw") as request_ui_redraw:
|
||||
tx_handler.onAckNak(packet)
|
||||
|
||||
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Ack")
|
||||
request_ui_redraw.assert_called_once_with(messages=True)
|
||||
self.assertIn(config.sent_message_prefix, ui_state.all_messages["Primary"][0][0])
|
||||
self.assertIn(config.ack_str, ui_state.all_messages["Primary"][0][0])
|
||||
|
||||
def test_on_ack_nak_uses_implicit_marker_for_self_ack(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.all_messages = {"Primary": [("pending", "hello")]}
|
||||
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
|
||||
|
||||
packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
|
||||
|
||||
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
|
||||
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
|
||||
with mock.patch("contact.ui.contact_ui.request_ui_redraw"):
|
||||
tx_handler.onAckNak(packet)
|
||||
|
||||
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Implicit")
|
||||
self.assertIn(config.ack_implicit_str, ui_state.all_messages["Primary"][0][0])
|
||||
93
tests/test_utils.py
Normal file
93
tests/test_utils.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
from contact.utilities.utils import add_new_message, get_channels, get_node_list, parse_protobuf
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class UtilsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config("node_sort")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
|
||||
def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None:
|
||||
config.node_sort = "lastHeard"
|
||||
interface = build_demo_interface()
|
||||
interface_state.interface = interface
|
||||
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
|
||||
|
||||
node_list = get_node_list()
|
||||
|
||||
self.assertEqual(node_list[0], DEMO_LOCAL_NODE_NUM)
|
||||
self.assertEqual(node_list[-1], 0xA1000008)
|
||||
|
||||
def test_add_new_message_groups_messages_by_hour(self) -> None:
|
||||
ui_state.all_messages = {"MediumFast": []}
|
||||
|
||||
with mock.patch("contact.utilities.utils.time.time", side_effect=[1000, 1000]):
|
||||
with mock.patch("contact.utilities.utils.time.strftime", return_value="[00:16:40] "):
|
||||
with mock.patch("contact.utilities.utils.datetime.datetime") as mocked_datetime:
|
||||
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "2025-02-04 17:00"
|
||||
add_new_message("MediumFast", ">> Test: ", "First")
|
||||
add_new_message("MediumFast", ">> Test: ", "Second")
|
||||
|
||||
self.assertEqual(
|
||||
ui_state.all_messages["MediumFast"],
|
||||
[
|
||||
("-- 2025-02-04 17:00 --", ""),
|
||||
("[00:16:40] >> Test: ", "First"),
|
||||
("[00:16:40] >> Test: ", "Second"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_channels_populates_message_buckets_for_device_channels(self) -> None:
|
||||
interface_state.interface = build_demo_interface()
|
||||
ui_state.channel_list = []
|
||||
ui_state.all_messages = {}
|
||||
|
||||
channels = get_channels()
|
||||
|
||||
self.assertIn("MediumFast", channels)
|
||||
self.assertIn("Another Channel", channels)
|
||||
self.assertIn("MediumFast", ui_state.all_messages)
|
||||
self.assertIn("Another Channel", ui_state.all_messages)
|
||||
|
||||
def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None:
|
||||
interface = build_demo_interface()
|
||||
interface.localNode.channels[0].settings.name = "Renamed Channel"
|
||||
interface_state.interface = interface
|
||||
ui_state.channel_list = ["MediumFast", "Another Channel", 2701131788]
|
||||
ui_state.all_messages = {
|
||||
"MediumFast": [("prefix", "first")],
|
||||
"Another Channel": [("prefix", "second")],
|
||||
2701131788: [("prefix", "dm")],
|
||||
}
|
||||
ui_state.selected_channel = 2
|
||||
|
||||
channels = get_channels()
|
||||
|
||||
self.assertEqual(channels[0], "Renamed Channel")
|
||||
self.assertEqual(channels[1], "Another Channel")
|
||||
self.assertEqual(channels[2], 2701131788)
|
||||
self.assertEqual(ui_state.all_messages["Renamed Channel"], [("prefix", "first")])
|
||||
self.assertEqual(ui_state.all_messages["Another Channel"], [("prefix", "second")])
|
||||
self.assertEqual(ui_state.all_messages[2701131788], [("prefix", "dm")])
|
||||
self.assertNotIn("MediumFast", ui_state.all_messages)
|
||||
|
||||
def test_parse_protobuf_returns_string_payload_unchanged(self) -> None:
|
||||
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": "hello"}}
|
||||
|
||||
self.assertEqual(parse_protobuf(packet), "hello")
|
||||
|
||||
def test_parse_protobuf_returns_placeholder_for_text_messages(self) -> None:
|
||||
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}}
|
||||
|
||||
self.assertEqual(parse_protobuf(packet), "✉️")
|
||||
14
tests/test_validation_rules.py
Normal file
14
tests/test_validation_rules.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import unittest
|
||||
|
||||
from contact.utilities.validation_rules import get_validation_for
|
||||
|
||||
|
||||
class ValidationRulesTests(unittest.TestCase):
|
||||
def test_get_validation_for_matches_exact_keys(self) -> None:
|
||||
self.assertEqual(get_validation_for("shortName"), {"max_length": 4})
|
||||
|
||||
def test_get_validation_for_matches_substrings(self) -> None:
|
||||
self.assertEqual(get_validation_for("config.position.latitude"), {"min_value": -90, "max_value": 90})
|
||||
|
||||
def test_get_validation_for_returns_empty_dict_for_unknown_key(self) -> None:
|
||||
self.assertEqual(get_validation_for("totally_unknown"), {})
|
||||
Reference in New Issue
Block a user