mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
1 Commits
1.4.5
...
client-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f270b5ba5 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,5 +8,4 @@ client.log
|
||||
settings.log
|
||||
config.json
|
||||
default_config.log
|
||||
dist/
|
||||
.vscode/launch.json
|
||||
dist/
|
||||
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@@ -1,22 +1,13 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "Python Debugger: main",
|
||||
"name": "Python Debugger: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": []
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: tcp",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": ["--host","192.168.86.69"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
38
README.md
38
README.md
@@ -1,19 +1,8 @@
|
||||
## Contact - A Console UI for Meshtastic
|
||||
### (Formerly Curses Client for Meshtastic)
|
||||
|
||||
#### Powered by Meshtastic.org
|
||||
|
||||
### Install with:
|
||||
```bash
|
||||
pip install contact
|
||||
```
|
||||
> [!NOTE]
|
||||
> Windows users must also install:
|
||||
>
|
||||
> ```powershell
|
||||
> pip install windows-curses
|
||||
> ```
|
||||
> because the built-in curses module is not available on Windows.
|
||||
|
||||
This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores.
|
||||
|
||||
|
||||
@@ -22,7 +11,7 @@ This Python curses client for Meshtastic is a terminal-based client designed to
|
||||
<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" />
|
||||
<img width="573" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dbe1287b-5558-407c-84b8-2a1bc913dec8" />
|
||||
|
||||
## Message Persistence
|
||||
|
||||
@@ -32,24 +21,14 @@ All messages will saved in a SQLite DB and restored upon relaunch of the app. Y
|
||||
|
||||
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
|
||||
|
||||
For smaller displays you may wish to enable `single_pane_mode`:
|
||||
|
||||
<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54 PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />
|
||||
|
||||
## Commands
|
||||
|
||||
- `CTRL` + `k` = display a list of commands.
|
||||
- `↑→↓←` = Navigate around the UI.
|
||||
- `F1/F2/F3` = Jump to Channel/Messages/Nodes
|
||||
- `ENTER` = Send a message typed in the Input Window, or with the Node List highlighted, select a node to DM
|
||||
- `` ` ` or F12` = Open the Settings dialogue
|
||||
- `` ` `` = Open the Settings dialogue
|
||||
- `CTRL` + `p` = Hide/show a log of raw received packets.
|
||||
- `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node
|
||||
- `F5` = Display a node's info
|
||||
- `CTRL` + `f` = With the Node List highlighted, favorite the selected node
|
||||
- `CTRL` + `g` = With the Node List highlighted, ignore the selected node
|
||||
- `CTRL` + `t` = With the Node List highlighted, send a traceroute to the selected node
|
||||
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
|
||||
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
|
||||
- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed.
|
||||
|
||||
### Search
|
||||
@@ -79,17 +58,8 @@ If no connection arguments are specified, the client will attempt a serial conne
|
||||
contact --port /dev/ttyUSB0
|
||||
contact --host 192.168.1.1
|
||||
contact --ble BlAddressOfDevice
|
||||
contact --port COM3
|
||||
```
|
||||
To quickly connect to localhost, use:
|
||||
```sh
|
||||
contact -t
|
||||
```
|
||||
## Install in development (editable) mode:
|
||||
```bash
|
||||
git clone https://github.com/pdxlocations/contact.git
|
||||
cd contact
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
@@ -1,147 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
'''
|
||||
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
|
||||
Powered by Meshtastic.org
|
||||
|
||||
Meshtastic® is a registered trademark of Meshtastic LLC.
|
||||
Meshtastic software components are released under various licenses—see GitHub for details.
|
||||
No warranty is provided. Use at your own risk.
|
||||
"""
|
||||
Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses, see GitHub for details. No warranty is provided - use at your own risk.
|
||||
'''
|
||||
|
||||
# Standard library
|
||||
import contextlib
|
||||
import curses
|
||||
import os
|
||||
from pubsub import pub
|
||||
import sys
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
import threading
|
||||
|
||||
# Third-party
|
||||
from pubsub import pub
|
||||
|
||||
# Local application
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
|
||||
from contact.message_handlers.rx_handler import on_receive
|
||||
from contact.settings import set_region
|
||||
from contact.ui.colors import setup_colors
|
||||
from contact.ui.contact_ui import main_ui
|
||||
from contact.ui.colors import setup_colors
|
||||
from contact.ui.splash import draw_splash
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import initialize_interface
|
||||
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Environment & Logging Setup
|
||||
# ------------------------------------------------------------------------------
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.utils import get_channels, get_node_list, get_nodeNum
|
||||
import contact.globals as globals
|
||||
|
||||
# Set ncurses compatibility settings
|
||||
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
|
||||
os.environ["LANG"] = "C.UTF-8"
|
||||
os.environ.setdefault("TERM", "xterm-256color")
|
||||
if os.environ.get("COLORTERM") == "gnome-terminal":
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
|
||||
# Configure logging
|
||||
# Run `tail -f client.log` in another terminal to view live
|
||||
logging.basicConfig(
|
||||
filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
filename=config.log_file_path,
|
||||
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
app_state.lock = threading.Lock()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Main Program Logic
|
||||
# ------------------------------------------------------------------------------
|
||||
def prompt_region_if_unset(args: object) -> None:
|
||||
"""Prompt user to set region if it is unset."""
|
||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
set_region(interface_state.interface)
|
||||
interface_state.interface.close()
|
||||
interface_state.interface = initialize_interface(args)
|
||||
|
||||
|
||||
def initialize_globals() -> None:
|
||||
"""Initializes interface and shared globals."""
|
||||
|
||||
interface_state.myNodeNum = get_nodeNum()
|
||||
ui_state.channel_list = get_channels()
|
||||
ui_state.node_list = get_node_list()
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
pub.subscribe(on_receive, "meshtastic.receive")
|
||||
|
||||
init_nodedb()
|
||||
load_messages_from_db()
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
"""Main entry point for the curses UI."""
|
||||
globals.lock = threading.Lock()
|
||||
|
||||
def main(stdscr):
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
setup_colors()
|
||||
draw_splash(stdscr)
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
|
||||
args = setup_parser().parse_args()
|
||||
setup_colors()
|
||||
draw_splash(stdscr)
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if getattr(args, "settings", False):
|
||||
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
|
||||
return
|
||||
# Check if --settings was passed and run settings.py as a subprocess
|
||||
if getattr(args, 'settings', False):
|
||||
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
|
||||
return
|
||||
|
||||
logging.info("Initializing interface...")
|
||||
with app_state.lock:
|
||||
interface_state.interface = initialize_interface(args)
|
||||
logging.info("Initializing interface %s", args)
|
||||
with globals.lock:
|
||||
globals.interface = initialize_interface(args)
|
||||
|
||||
if interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||
prompt_region_if_unset(args)
|
||||
if globals.interface.localNode.localConfig.lora.region == 0:
|
||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
set_region(globals.interface)
|
||||
globals.interface.close()
|
||||
globals.interface = initialize_interface(args)
|
||||
|
||||
initialize_globals()
|
||||
logging.info("Starting main UI")
|
||||
logging.info("Interface initialized")
|
||||
globals.myNodeNum = get_nodeNum()
|
||||
globals.channel_list = get_channels()
|
||||
globals.node_list = get_node_list()
|
||||
pub.subscribe(on_receive, 'meshtastic.receive')
|
||||
init_nodedb()
|
||||
load_messages_from_db()
|
||||
logging.info("Starting main UI")
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
main_ui(stdscr)
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
main_ui(stdscr)
|
||||
except Exception:
|
||||
console_output = output_capture.getvalue()
|
||||
logging.error("Uncaught exception inside main_ui")
|
||||
logging.error("Traceback:\n%s", traceback.format_exc())
|
||||
logging.error("Console output:\n%s", console_output)
|
||||
return
|
||||
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Entry point for the application."""
|
||||
|
||||
if "--help" in sys.argv or "-h" in sys.argv:
|
||||
setup_parser().print_help()
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("User exited with Ctrl+C")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logging.critical("Fatal error", exc_info=True)
|
||||
try:
|
||||
curses.endwin()
|
||||
except Exception:
|
||||
pass
|
||||
print("Fatal error:", e)
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
console_output = output_capture.getvalue()
|
||||
logging.error("An error occurred: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
logging.error("Console output before crash:\n%s", console_output)
|
||||
raise # Re-raise only unexpected errors
|
||||
|
||||
def start():
|
||||
log_file = config.log_file_path
|
||||
log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes
|
||||
|
||||
sys.stdout = log_f
|
||||
sys.stderr = log_f
|
||||
|
||||
with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f):
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("User exited with Ctrl+C or Ctrl+X") # Clean exit logging
|
||||
sys.exit(0) # Ensure a clean exit
|
||||
except Exception as e:
|
||||
logging.error("Fatal error in curses wrapper: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
sys.exit(1) # Exit with an error code
|
||||
|
||||
if __name__ == "__main__":
|
||||
start()
|
||||
|
||||
13
contact/globals.py
Normal file
13
contact/globals.py
Normal file
@@ -0,0 +1,13 @@
|
||||
interface = None
|
||||
lock = None
|
||||
display_log = False
|
||||
all_messages = {}
|
||||
channel_list = []
|
||||
notifications = []
|
||||
packet_buffer = []
|
||||
node_list = []
|
||||
myNodeNum = 0
|
||||
selected_channel = 0
|
||||
selected_message = 0
|
||||
selected_node = 0
|
||||
current_window = 0
|
||||
@@ -14,13 +14,13 @@ id, "", ""
|
||||
uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT server configured on this node."
|
||||
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
|
||||
module_settings, "Module settings", "Position precision and Client Mute."
|
||||
module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel."
|
||||
module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable."
|
||||
position_precision, "Position precision", "The precision level of location data sent on this channel."
|
||||
is_client_muted, "", ""
|
||||
|
||||
[config.device]
|
||||
title, "Device"
|
||||
role, "Role", "For the vast majority of users, the correct choice is CLIENT. See Meshtastic docs for more information."
|
||||
serial_enabled, "Enable serial console", "Serial Console over the Stream API."
|
||||
serial_enabled, "Enable serial console", ""
|
||||
button_gpio, "Button GPIO", "GPIO pin for user button."
|
||||
buzzer_gpio, "Buzzer GPIO", "GPIO pin for user buzzer."
|
||||
rebroadcast_mode, "Rebroadcast mode", "This setting defines the device's behavior for how messages are rebroadcast."
|
||||
@@ -30,7 +30,6 @@ is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps
|
||||
disable_triple_click, "Disable triple button press", ""
|
||||
tzdef, "Timezone", "Uses the TZ Database format to display the correct local time on the device display and in its logs."
|
||||
led_heartbeat_disabled, "Disable LED heartbeat", "On certain hardware models, this disables the blinking heartbeat LED."
|
||||
buzzer_mode, "Buzzer Mode", "Controls buzzer behavior for audio feedback."
|
||||
|
||||
[config.position]
|
||||
title, "Position"
|
||||
@@ -78,7 +77,6 @@ subnet, "IPv4 subnet", ""
|
||||
dns, "IPv4 DNS server", ""
|
||||
rsyslog_server, "RSyslog server", ""
|
||||
enabled_protocols, "Enabled protocols", ""
|
||||
ipv6_enabled, "IPv6 enabled", "Enables or Disables IPv6 networking."
|
||||
|
||||
[config.network.ipv4_config]
|
||||
title, "IPv4 Config", ""
|
||||
@@ -160,7 +158,7 @@ channel_num, "Frequency slot", "Determines the exact frequency the radio transmi
|
||||
override_duty_cycle, "Override duty cycle", "Override the legal transmit time limit to allow unlimited transmit time. [warning]May have legal ramifications.[/warning]"
|
||||
sx126x_rx_boosted_gain, "Enable SX126X RX boosted gain", "This is an option specific to the SX126x chip series which allows the chip to consume a small amount of additional power to increase RX (receive) sensitivity."
|
||||
override_frequency, "Override frequency in MHz", "Overrides frequency slot. May have legal ramifications."
|
||||
pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN"
|
||||
pa_fan_disabled, "", ""
|
||||
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
|
||||
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
|
||||
|
||||
@@ -192,10 +190,8 @@ tls_enabled, "TLS enabled", "If true, we attempt to establish a secure connectio
|
||||
root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs."
|
||||
proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings."
|
||||
map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet."
|
||||
map_report_settings, "Map report settings", "Settings for the map report module."
|
||||
map_report_settings.publish_interval_secs, "Map report publish interval", "How often we should publish the map report to the MQTT server in seconds. Defaults to 900 seconds (15 minutes)."
|
||||
map_report_settings.position_precision, "Map report position precision", "The precision to use for the position in the map report. Defaults to a maximum deviation of around 1459m."
|
||||
map_report_settings.should_report_location, "Should report location", "Whether we have opted-in to report our location to the map."
|
||||
|
||||
[module.serial]
|
||||
title, "Serial"
|
||||
@@ -212,9 +208,9 @@ override_console_serial_port, "Override console serial port", "If set to true, t
|
||||
title, "External Notification"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
|
||||
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
|
||||
output_vibra, "Vibra GPIO", "Optional: Define a secondary output pin for a vibra motor. This is used in standalone devices to match the UI."
|
||||
output_buzzer, "Buzzer GPIO", "Optional: Define a tertiary output pin for an active buzze. This is used in standalone devices to to match the UI."
|
||||
output, "", ""
|
||||
output_vibra, "", ""
|
||||
output_buzzer, "", ""
|
||||
active, "Active (general / LED only)", "Specifies whether the external circuit is active when the device's GPIO is low or high. If this is set true, the pin will be pulled active high, false means active low."
|
||||
alert_message, "Alert when receiving a message (general)", "Specifies if an alert should be triggered when receiving an incoming message."
|
||||
alert_message_vibra, "Alert vibration on message", "Specifies if a vibration alert should be triggered when receiving an incoming message."
|
||||
@@ -284,8 +280,7 @@ i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface."
|
||||
[module.remote_hardware]
|
||||
title, "Remote Hardware"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
allow_undefined_pin_access, "Allow undefined pin access", "Whether the Module allows consumers to read / write to pins not defined in available_pins"
|
||||
available_pins, "Available pins", "Exposes the available pins to the mesh for reading and writing."
|
||||
allow_undefined_pin_access, "Allow undefined pin access", ""
|
||||
|
||||
[module.neighbor_info]
|
||||
title, "Neighbor Info"
|
||||
@@ -316,5 +311,5 @@ use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. O
|
||||
title, "Paxcounter"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
|
||||
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80"
|
||||
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
|
||||
Wi-Fi_threshold, "", ""
|
||||
ble_threshold, "", ""
|
||||
@@ -1,158 +1,105 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
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,
|
||||
)
|
||||
from contact.utilities.db_handler import (
|
||||
save_message_to_db,
|
||||
maybe_store_nodeinfo_in_db,
|
||||
get_name_from_database,
|
||||
update_node_info_in_db,
|
||||
)
|
||||
import time
|
||||
from contact.utilities.utils import refresh_node_list
|
||||
from datetime import datetime
|
||||
from contact.ui.contact_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification
|
||||
from contact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db
|
||||
import contact.ui.default_config as config
|
||||
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
|
||||
import contact.globals as globals
|
||||
|
||||
|
||||
def play_sound():
|
||||
try:
|
||||
system = platform.system()
|
||||
sound_path = None
|
||||
executable = None
|
||||
from datetime import datetime
|
||||
|
||||
if system == "Darwin": # macOS
|
||||
sound_path = "/System/Library/Sounds/Ping.aiff"
|
||||
executable = "afplay"
|
||||
def on_receive(packet, interface):
|
||||
|
||||
elif system == "Linux":
|
||||
ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga"
|
||||
wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback
|
||||
|
||||
if shutil.which("paplay") and os.path.exists(ogg_path):
|
||||
executable = "paplay"
|
||||
sound_path = ogg_path
|
||||
elif shutil.which("ffplay") and os.path.exists(ogg_path):
|
||||
executable = "ffplay"
|
||||
sound_path = ogg_path
|
||||
elif shutil.which("aplay") and os.path.exists(wav_path):
|
||||
executable = "aplay"
|
||||
sound_path = wav_path
|
||||
else:
|
||||
logging.warning("No suitable sound player or sound file found on Linux")
|
||||
|
||||
if executable and sound_path:
|
||||
cmd = [executable, sound_path]
|
||||
if executable == "ffplay":
|
||||
cmd = [executable, "-nodisp", "-autoexit", sound_path]
|
||||
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Sound playback failed: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error: {e}")
|
||||
|
||||
|
||||
def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
"""
|
||||
Handles an incoming packet from a Meshtastic interface.
|
||||
|
||||
Args:
|
||||
packet: The received Meshtastic packet as a dictionary.
|
||||
interface: The Meshtastic interface instance that received the packet.
|
||||
"""
|
||||
with app_state.lock:
|
||||
with globals.lock:
|
||||
# Update packet log
|
||||
ui_state.packet_buffer.append(packet)
|
||||
if len(ui_state.packet_buffer) > 20:
|
||||
globals.packet_buffer.append(packet)
|
||||
if len(globals.packet_buffer) > 20:
|
||||
# Trim buffer to 20 packets
|
||||
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
|
||||
|
||||
if ui_state.display_log:
|
||||
globals.packet_buffer = globals.packet_buffer[-20:]
|
||||
|
||||
if globals.display_log:
|
||||
draw_packetlog_win()
|
||||
|
||||
if ui_state.current_window == 4:
|
||||
menu_state.need_redraw = True
|
||||
try:
|
||||
if "decoded" not in packet:
|
||||
if 'decoded' not in packet:
|
||||
return
|
||||
|
||||
# Assume any incoming packet could update the last seen time for a node
|
||||
changed = refresh_node_list()
|
||||
if changed:
|
||||
if(changed):
|
||||
draw_node_list()
|
||||
|
||||
if packet["decoded"]["portnum"] == "NODEINFO_APP":
|
||||
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
|
||||
if packet['decoded']['portnum'] == 'NODEINFO_APP':
|
||||
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
|
||||
maybe_store_nodeinfo_in_db(packet)
|
||||
|
||||
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
|
||||
hop_start = packet.get('hopStart', 0)
|
||||
hop_limit = packet.get('hopLimit', 0)
|
||||
|
||||
hops = hop_start - hop_limit
|
||||
|
||||
|
||||
if config.notification_sound == "True":
|
||||
play_sound()
|
||||
|
||||
message_bytes = packet["decoded"]["payload"]
|
||||
message_string = message_bytes.decode("utf-8")
|
||||
elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
|
||||
message_bytes = packet['decoded']['payload']
|
||||
message_string = message_bytes.decode('utf-8')
|
||||
|
||||
refresh_channels = False
|
||||
refresh_messages = False
|
||||
|
||||
if packet.get("channel"):
|
||||
channel_number = packet["channel"]
|
||||
if packet.get('channel'):
|
||||
channel_number = packet['channel']
|
||||
else:
|
||||
channel_number = 0
|
||||
|
||||
if packet["to"] == interface_state.myNodeNum:
|
||||
if packet["from"] in ui_state.channel_list:
|
||||
if packet['to'] == globals.myNodeNum:
|
||||
if packet['from'] in globals.channel_list:
|
||||
pass
|
||||
else:
|
||||
ui_state.channel_list.append(packet["from"])
|
||||
if packet["from"] not in ui_state.all_messages:
|
||||
ui_state.all_messages[packet["from"]] = []
|
||||
update_node_info_in_db(packet["from"], chat_archived=False)
|
||||
globals.channel_list.append(packet['from'])
|
||||
if(packet['from'] not in globals.all_messages):
|
||||
globals.all_messages[packet['from']] = []
|
||||
update_node_info_in_db(packet['from'], chat_archived=False)
|
||||
refresh_channels = True
|
||||
|
||||
channel_number = ui_state.channel_list.index(packet["from"])
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
channel_id = ui_state.channel_list[channel_number]
|
||||
|
||||
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
|
||||
if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]:
|
||||
add_notification(channel_number)
|
||||
refresh_channels = True
|
||||
else:
|
||||
refresh_messages = True
|
||||
|
||||
# Add received message to the messages list
|
||||
message_from_id = packet["from"]
|
||||
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
|
||||
message_from_id = packet['from']
|
||||
message_from_string = get_name_from_database(message_from_id, type='short') + ":"
|
||||
|
||||
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
|
||||
if globals.channel_list[channel_number] not in globals.all_messages:
|
||||
globals.all_messages[globals.channel_list[channel_number]] = []
|
||||
|
||||
# Timestamp handling
|
||||
current_timestamp = time.time()
|
||||
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
|
||||
|
||||
# Retrieve the last timestamp if available
|
||||
channel_messages = globals.all_messages[globals.channel_list[channel_number]]
|
||||
if channel_messages:
|
||||
# Check the last entry for a timestamp
|
||||
for entry in reversed(channel_messages):
|
||||
if entry[0].startswith("--"):
|
||||
last_hour = entry[0].strip("- ").strip()
|
||||
break
|
||||
else:
|
||||
last_hour = None
|
||||
else:
|
||||
last_hour = None
|
||||
|
||||
# Add a new timestamp if it's a new hour
|
||||
if last_hour != current_hour:
|
||||
globals.all_messages[globals.channel_list[channel_number]].append((f"-- {current_hour} --", ""))
|
||||
|
||||
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string} ", message_string))
|
||||
|
||||
if refresh_channels:
|
||||
draw_channel_list()
|
||||
if refresh_messages:
|
||||
draw_messages_window(True)
|
||||
|
||||
save_message_to_db(channel_id, message_from_id, message_string)
|
||||
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
|
||||
|
||||
except KeyError as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
import time
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from datetime import datetime
|
||||
import google.protobuf.json_format
|
||||
from meshtastic import BROADCAST_NUM
|
||||
from meshtastic.protobuf import mesh_pb2, portnums_pb2
|
||||
|
||||
from contact.utilities.db_handler import (
|
||||
save_message_to_db,
|
||||
update_ack_nak,
|
||||
get_name_from_database,
|
||||
is_chat_archived,
|
||||
update_node_info_in_db,
|
||||
)
|
||||
from contact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db
|
||||
import contact.ui.default_config as config
|
||||
import contact.globals as globals
|
||||
|
||||
from contact.utilities.singleton import ui_state, interface_state
|
||||
|
||||
from contact.utilities.utils import add_new_message
|
||||
|
||||
ack_naks: Dict[str, Dict[str, Any]] = {} # requestId -> {channel, messageIndex, timestamp}
|
||||
|
||||
ack_naks = {}
|
||||
|
||||
# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention
|
||||
# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462
|
||||
def onAckNak(packet: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Handles incoming ACK/NAK response packets.
|
||||
"""
|
||||
def onAckNak(packet):
|
||||
from contact.ui.contact_ui import draw_messages_window
|
||||
|
||||
request = packet["decoded"]["requestId"]
|
||||
if request not in ack_naks:
|
||||
request = packet['decoded']['requestId']
|
||||
if(request not in ack_naks):
|
||||
return
|
||||
|
||||
acknak = ack_naks.pop(request)
|
||||
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
|
||||
message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1]
|
||||
|
||||
confirm_string = " "
|
||||
ack_type = None
|
||||
if packet["decoded"]["routing"]["errorReason"] == "NONE":
|
||||
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
|
||||
if(packet['decoded']['routing']['errorReason'] == "NONE"):
|
||||
if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK
|
||||
confirm_string = config.ack_implicit_str
|
||||
ack_type = "Implicit"
|
||||
else:
|
||||
@@ -50,28 +33,22 @@ def onAckNak(packet: Dict[str, Any]) -> None:
|
||||
confirm_string = config.nak_str
|
||||
ack_type = "Nak"
|
||||
|
||||
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
|
||||
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
|
||||
message,
|
||||
)
|
||||
globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message)
|
||||
|
||||
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
|
||||
update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type)
|
||||
|
||||
channel_number = ui_state.channel_list.index(acknak["channel"])
|
||||
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
|
||||
channel_number = globals.channel_list.index(acknak['channel'])
|
||||
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
|
||||
draw_messages_window()
|
||||
|
||||
|
||||
def on_response_traceroute(packet: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle traceroute response packets and render the route visually in the UI.
|
||||
"""
|
||||
def on_response_traceroute(packet):
|
||||
"""on response for trace route"""
|
||||
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
|
||||
|
||||
refresh_channels = False
|
||||
refresh_messages = False
|
||||
|
||||
UNK_SNR = -128 # Value representing unknown SNR
|
||||
UNK_SNR = -128 # Value representing unknown SNR
|
||||
|
||||
route_discovery = mesh_pb2.RouteDiscovery()
|
||||
route_discovery.ParseFromString(packet["decoded"]["payload"])
|
||||
@@ -79,109 +56,79 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
|
||||
|
||||
msg_str = "Traceroute to:\n"
|
||||
|
||||
route_str = (
|
||||
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
|
||||
) # Start with destination of response
|
||||
route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response
|
||||
|
||||
# SNR list should have one more entry than the route, as the final destination adds its SNR also
|
||||
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
|
||||
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
|
||||
if lenTowards > 0: # Loop through hops in route and add SNR if available
|
||||
if lenTowards > 0: # Loop through hops in route and add SNR if available
|
||||
for idx, node_num in enumerate(msg_dict["route"]):
|
||||
route_str += (
|
||||
" --> "
|
||||
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
|
||||
+ " ("
|
||||
+ (
|
||||
str(msg_dict["snrTowards"][idx] / 4)
|
||||
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
|
||||
else "?"
|
||||
)
|
||||
+ "dB)"
|
||||
)
|
||||
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
|
||||
+ " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
# End with origin of response
|
||||
route_str += (
|
||||
" --> "
|
||||
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
|
||||
+ " ("
|
||||
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
|
||||
+ "dB)"
|
||||
)
|
||||
route_str += " --> " + (get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}") \
|
||||
+ " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
msg_str += route_str + "\n" # Print the route towards destination
|
||||
msg_str += route_str + "\n" # Print the route towards destination
|
||||
|
||||
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
|
||||
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
|
||||
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
|
||||
if backValid:
|
||||
msg_str += "Back:\n"
|
||||
route_str = (
|
||||
get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
|
||||
) # Start with origin of response
|
||||
route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response
|
||||
|
||||
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
|
||||
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
|
||||
for idx, node_num in enumerate(msg_dict["routeBack"]):
|
||||
route_str += (
|
||||
" --> "
|
||||
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
|
||||
+ " ("
|
||||
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
|
||||
+ "dB)"
|
||||
)
|
||||
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
|
||||
+ " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
# End with destination of response (us)
|
||||
route_str += (
|
||||
" --> "
|
||||
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
|
||||
+ " ("
|
||||
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
|
||||
+ "dB)"
|
||||
)
|
||||
route_str += " --> " + (get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}") \
|
||||
+ " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
msg_str += route_str + "\n" # Print the route back to us
|
||||
msg_str += route_str + "\n" # Print the route back to us
|
||||
|
||||
if packet["from"] not in ui_state.channel_list:
|
||||
ui_state.channel_list.append(packet["from"])
|
||||
if(packet['from'] not in globals.channel_list):
|
||||
globals.channel_list.append(packet['from'])
|
||||
refresh_channels = True
|
||||
|
||||
if is_chat_archived(packet["from"]):
|
||||
update_node_info_in_db(packet["from"], chat_archived=False)
|
||||
if(is_chat_archived(packet['from'])):
|
||||
update_node_info_in_db(packet['from'], chat_archived=False)
|
||||
|
||||
channel_number = ui_state.channel_list.index(packet["from"])
|
||||
channel_id = ui_state.channel_list[channel_number]
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
|
||||
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
|
||||
refresh_messages = True
|
||||
else:
|
||||
add_notification(channel_number)
|
||||
refresh_channels = True
|
||||
|
||||
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
|
||||
message_from_string = get_name_from_database(packet['from'], type='short') + ":\n"
|
||||
|
||||
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
|
||||
if globals.channel_list[channel_number] not in globals.all_messages:
|
||||
globals.all_messages[globals.channel_list[channel_number]] = []
|
||||
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string}", msg_str))
|
||||
|
||||
if refresh_channels:
|
||||
draw_channel_list()
|
||||
if refresh_messages:
|
||||
draw_messages_window(True)
|
||||
save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str)
|
||||
|
||||
save_message_to_db(channel_id, packet["from"], msg_str)
|
||||
|
||||
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
|
||||
"""
|
||||
Sends a chat message using the selected channel.
|
||||
"""
|
||||
myid = interface_state.myNodeNum
|
||||
def send_message(message, destination=BROADCAST_NUM, channel=0):
|
||||
myid = globals.myNodeNum
|
||||
send_on_channel = 0
|
||||
channel_id = ui_state.channel_list[channel]
|
||||
channel_id = globals.channel_list[channel]
|
||||
if isinstance(channel_id, int):
|
||||
send_on_channel = 0
|
||||
destination = channel_id
|
||||
elif isinstance(channel_id, str):
|
||||
send_on_channel = channel
|
||||
|
||||
sent_message_data = interface_state.interface.sendText(
|
||||
sent_message_data = globals.interface.sendText(
|
||||
text=message,
|
||||
destinationId=destination,
|
||||
wantAck=True,
|
||||
@@ -190,29 +137,42 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
|
||||
channelIndex=send_on_channel,
|
||||
)
|
||||
|
||||
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
|
||||
# Add sent message to the messages dictionary
|
||||
if channel_id not in globals.all_messages:
|
||||
globals.all_messages[channel_id] = []
|
||||
|
||||
# Handle timestamp logic
|
||||
current_timestamp = int(datetime.now().timestamp()) # Get current timestamp
|
||||
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
|
||||
|
||||
# Retrieve the last timestamp if available
|
||||
channel_messages = globals.all_messages[channel_id]
|
||||
if channel_messages:
|
||||
# Check the last entry for a timestamp
|
||||
for entry in reversed(channel_messages):
|
||||
if entry[0].startswith("--"):
|
||||
last_hour = entry[0].strip("- ").strip()
|
||||
break
|
||||
else:
|
||||
last_hour = None
|
||||
else:
|
||||
last_hour = None
|
||||
|
||||
# Add a new timestamp if it's a new hour
|
||||
if last_hour != current_hour:
|
||||
globals.all_messages[channel_id].append((f"-- {current_hour} --", ""))
|
||||
|
||||
globals.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message))
|
||||
|
||||
timestamp = save_message_to_db(channel_id, myid, message)
|
||||
|
||||
ack_naks[sent_message_data.id] = {
|
||||
"channel": channel_id,
|
||||
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
|
||||
def send_traceroute() -> None:
|
||||
"""
|
||||
Sends a RouteDiscovery protobuf to the selected node.
|
||||
"""
|
||||
|
||||
channel_id = ui_state.node_list[ui_state.selected_node]
|
||||
add_new_message(channel_id, f"{config.message_prefix} Sent Traceroute", "")
|
||||
ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp}
|
||||
|
||||
def send_traceroute():
|
||||
r = mesh_pb2.RouteDiscovery()
|
||||
interface_state.interface.sendData(
|
||||
globals.interface.sendData(
|
||||
r,
|
||||
destinationId=channel_id,
|
||||
destinationId=globals.node_list[globals.selected_node],
|
||||
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
|
||||
wantResponse=True,
|
||||
onResponse=on_response_traceroute,
|
||||
|
||||
@@ -14,10 +14,10 @@ from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.interfaces import initialize_interface
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
def main(stdscr):
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
setup_colors()
|
||||
draw_splash(stdscr)
|
||||
curses.curs_set(0)
|
||||
@@ -28,7 +28,7 @@ def main(stdscr: curses.window) -> None:
|
||||
interface = initialize_interface(args)
|
||||
|
||||
if interface.localNode.localConfig.lora.region == 0:
|
||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
set_region(interface)
|
||||
interface.close()
|
||||
@@ -45,10 +45,10 @@ def main(stdscr: curses.window) -> None:
|
||||
raise
|
||||
|
||||
|
||||
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
|
||||
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
|
||||
filename=config.log_file_path,
|
||||
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -67,4 +67,4 @@ if __name__ == "__main__":
|
||||
except Exception as e:
|
||||
logging.error("Fatal error in curses wrapper: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
sys.exit(1) # Exit with an error code
|
||||
sys.exit(1) # Exit with an error code
|
||||
@@ -9,11 +9,10 @@ COLOR_MAP = {
|
||||
"blue": curses.COLOR_BLUE,
|
||||
"magenta": curses.COLOR_MAGENTA,
|
||||
"cyan": curses.COLOR_CYAN,
|
||||
"white": curses.COLOR_WHITE,
|
||||
"white": curses.COLOR_WHITE
|
||||
}
|
||||
|
||||
|
||||
def setup_colors(reinit: bool = False) -> None:
|
||||
def setup_colors(reinit=False):
|
||||
"""
|
||||
Initialize curses color pairs based on the COLOR_CONFIG.
|
||||
"""
|
||||
@@ -30,7 +29,7 @@ def setup_colors(reinit: bool = False) -> None:
|
||||
print()
|
||||
|
||||
|
||||
def get_color(category: str, bold: bool = False, reverse: bool = False, underline: bool = False) -> int:
|
||||
def get_color(category, bold=False, reverse=False, underline=False):
|
||||
"""
|
||||
Retrieve a curses color pair with optional attributes.
|
||||
"""
|
||||
@@ -41,4 +40,4 @@ def get_color(category: str, bold: bool = False, reverse: bool = False, underlin
|
||||
color |= curses.A_REVERSE
|
||||
if underline:
|
||||
color |= curses.A_UNDERLINE
|
||||
return color
|
||||
return color
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,309 +2,415 @@ import base64
|
||||
import curses
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from contact.utilities.save_to_radio import save_changes
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.config_io import config_export, config_import
|
||||
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
|
||||
from contact.utilities.input_handlers import (
|
||||
get_repeated_input,
|
||||
get_text_input,
|
||||
get_fixed32_input,
|
||||
get_list_input,
|
||||
get_admin_key_input,
|
||||
)
|
||||
from contact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
from contact.ui.colors import get_color
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
|
||||
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
|
||||
from contact.ui.user_config import json_editor
|
||||
from contact.utilities.singleton import menu_state
|
||||
from contact.ui.ui_state import MenuState
|
||||
|
||||
# Setup Variables
|
||||
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||
state = MenuState()
|
||||
|
||||
|
||||
# Constants
|
||||
width = 80
|
||||
save_option = "Save Changes"
|
||||
max_help_lines = 0
|
||||
help_win = None
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
|
||||
|
||||
# Compute the effective menu width for the current terminal
|
||||
def get_menu_width() -> int:
|
||||
# Leave at least 2 columns for borders; clamp to >= 20 for usability
|
||||
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
|
||||
|
||||
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
|
||||
# Get the parent directory of the script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||
|
||||
# Paths
|
||||
# locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
|
||||
locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
|
||||
translation_file = os.path.join(parent_dir, "localisations", "en.ini")
|
||||
|
||||
# config_folder = os.path.join(locals_dir, "node-configs")
|
||||
config_folder = os.path.abspath(config.node_configs_file_path)
|
||||
config_folder = os.path.join(locals_dir, "node-configs")
|
||||
|
||||
# Load translations
|
||||
field_mapping, help_text = parse_ini_file(translation_file)
|
||||
|
||||
|
||||
def display_menu() -> tuple[object, object]:
|
||||
# if help_win:
|
||||
# min_help_window_height = 6
|
||||
# else:
|
||||
# min_help_window_height = 0
|
||||
def display_menu(state):
|
||||
|
||||
min_help_window_height = 6
|
||||
|
||||
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
|
||||
num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
|
||||
|
||||
# Determine the available height for the menu
|
||||
max_menu_height = curses.LINES
|
||||
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
|
||||
w = get_menu_width()
|
||||
max_menu_height = curses.LINES
|
||||
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
|
||||
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
|
||||
start_x = (curses.COLS - w) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Calculate remaining space for help window
|
||||
global max_help_lines
|
||||
remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding
|
||||
max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help
|
||||
|
||||
menu_win = curses.newwin(menu_height, w, start_y, start_x)
|
||||
menu_win = curses.newwin(menu_height, width, start_y, start_x)
|
||||
menu_win.erase()
|
||||
menu_win.bkgd(get_color("background"))
|
||||
menu_win.attrset(get_color("window_frame"))
|
||||
menu_win.border()
|
||||
menu_win.keypad(True)
|
||||
|
||||
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8)
|
||||
menu_pad = curses.newpad(len(state.current_menu) + 1, width - 8)
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
header = " > ".join(word.title() for word in menu_state.menu_path)
|
||||
if len(header) > w - 4:
|
||||
header = header[: w - 7] + "..."
|
||||
header = " > ".join(word.title() for word in state.menu_path)
|
||||
if len(header) > width - 4:
|
||||
header = header[:width - 7] + "..."
|
||||
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
|
||||
|
||||
transformed_path = transform_menu_path(menu_state.menu_path)
|
||||
transformed_path = transform_menu_path(state.menu_path)
|
||||
|
||||
for idx, option in enumerate(menu_state.current_menu):
|
||||
field_info = menu_state.current_menu[option]
|
||||
for idx, option in enumerate(state.current_menu):
|
||||
field_info = state.current_menu[option]
|
||||
current_value = field_info[1] if isinstance(field_info, tuple) else ""
|
||||
full_key = ".".join(transformed_path + [option])
|
||||
full_key = '.'.join(transformed_path + [option])
|
||||
display_name = field_mapping.get(full_key, option)
|
||||
|
||||
display_option = f"{display_name}"[: w // 2 - 2]
|
||||
display_value = f"{current_value}"[: w // 2 - 4]
|
||||
display_option = f"{display_name}"[:width // 2 - 2]
|
||||
display_value = f"{current_value}"[:width // 2 - 4]
|
||||
|
||||
try:
|
||||
color = get_color(
|
||||
"settings_sensitive" if option in sensitive_settings else "settings_default",
|
||||
reverse=(idx == menu_state.selected_index),
|
||||
)
|
||||
menu_pad.addstr(idx, 0, f"{display_option:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
|
||||
color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == state.selected_index))
|
||||
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
if menu_state.show_save_option:
|
||||
if state.show_save_option:
|
||||
save_position = menu_height - 2
|
||||
menu_win.addstr(
|
||||
save_position,
|
||||
(w - len(save_option)) // 2,
|
||||
save_option,
|
||||
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
|
||||
)
|
||||
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(state.current_menu))))
|
||||
|
||||
# Draw help window with dynamically updated max_help_lines
|
||||
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path)
|
||||
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, state)
|
||||
|
||||
menu_win.refresh()
|
||||
menu_pad.refresh(
|
||||
menu_state.start_index[-1],
|
||||
0,
|
||||
menu_win.getbegyx()[0] + 3,
|
||||
menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
|
||||
state.start_index[-1], 0,
|
||||
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0),
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
|
||||
)
|
||||
curses.curs_set(0)
|
||||
|
||||
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
|
||||
max_index = num_items + (1 if state.show_save_option else 0) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
|
||||
|
||||
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
|
||||
draw_arrows(menu_win, visible_height, max_index, state)
|
||||
|
||||
return menu_win, menu_pad
|
||||
|
||||
|
||||
def draw_help_window(
|
||||
menu_start_y: int,
|
||||
menu_start_x: int,
|
||||
menu_height: int,
|
||||
max_help_lines: int,
|
||||
transformed_path: List[str],
|
||||
) -> None:
|
||||
def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, transformed_path, state):
|
||||
global help_win
|
||||
|
||||
if "help_win" not in globals():
|
||||
if 'help_win' not in globals():
|
||||
help_win = None # Initialize if it does not exist
|
||||
|
||||
selected_option = (
|
||||
list(menu_state.current_menu.keys())[menu_state.selected_index] if menu_state.current_menu else None
|
||||
)
|
||||
selected_option = list(state.current_menu.keys())[state.selected_index] if state.current_menu else None
|
||||
help_y = menu_start_y + menu_height
|
||||
|
||||
# Use current terminal width for the help window width calculation
|
||||
help_win = update_help_window(
|
||||
help_win, help_text, transformed_path, selected_option, max_help_lines, get_menu_width(), help_y, menu_start_x
|
||||
)
|
||||
help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x)
|
||||
|
||||
def update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, help_x):
|
||||
"""Handles rendering the help window consistently."""
|
||||
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines)
|
||||
|
||||
def get_input_type_for_field(field) -> type:
|
||||
if field.type in (field.TYPE_INT32, field.TYPE_UINT32, field.TYPE_INT64):
|
||||
return int
|
||||
elif field.type in (field.TYPE_FLOAT, field.TYPE_DOUBLE):
|
||||
return float
|
||||
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
|
||||
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
|
||||
|
||||
# Ensure help window does not exceed screen size
|
||||
if help_y + help_height > curses.LINES:
|
||||
help_y = curses.LINES - help_height
|
||||
|
||||
# Create or update the help window
|
||||
if help_win is None:
|
||||
help_win = curses.newwin(help_height, width, help_y, help_x)
|
||||
else:
|
||||
return str
|
||||
help_win.erase()
|
||||
help_win.refresh()
|
||||
help_win.resize(help_height, width)
|
||||
help_win.mvwin(help_y, help_x)
|
||||
|
||||
help_win.bkgd(get_color("background"))
|
||||
help_win.attrset(get_color("window_frame"))
|
||||
help_win.border()
|
||||
|
||||
for idx, line_segments in enumerate(wrapped_help):
|
||||
x_pos = 2 # Start after border
|
||||
for text, color, bold, underline in line_segments:
|
||||
try:
|
||||
attr = get_color(color, bold=bold, underline=underline)
|
||||
help_win.addstr(1 + idx, x_pos, text, attr)
|
||||
x_pos += len(text)
|
||||
except curses.error:
|
||||
pass # Prevent crashes
|
||||
|
||||
help_win.refresh()
|
||||
return help_win
|
||||
|
||||
|
||||
def settings_menu(stdscr: object, interface: object) -> None:
|
||||
def get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_lines):
|
||||
"""Fetches and formats help text for display, ensuring it fits within the allowed lines."""
|
||||
|
||||
full_help_key = '.'.join(transformed_path + [selected_option]) if selected_option else None
|
||||
help_content = help_text.get(full_help_key, "No help available.")
|
||||
|
||||
wrap_width = max(width - 6, 10) # Ensure a valid wrapping width
|
||||
|
||||
# Color replacements
|
||||
color_mappings = {
|
||||
r'\[warning\](.*?)\[/warning\]': ('settings_warning', True, False), # Red for warnings
|
||||
r'\[note\](.*?)\[/note\]': ('settings_note', True, False), # Green for notes
|
||||
r'\[underline\](.*?)\[/underline\]': ('settings_default', False, True), # Underline
|
||||
|
||||
r'\\033\[31m(.*?)\\033\[0m': ('settings_warning', True, False), # Red text
|
||||
r'\\033\[32m(.*?)\\033\[0m': ('settings_note', True, False), # Green text
|
||||
r'\\033\[4m(.*?)\\033\[0m': ('settings_default', False, True) # Underline
|
||||
}
|
||||
|
||||
def extract_ansi_segments(text):
|
||||
"""Extracts and replaces ANSI color codes, ensuring spaces are preserved."""
|
||||
matches = []
|
||||
last_pos = 0
|
||||
pattern_matches = []
|
||||
|
||||
# Find all matches and store their positions
|
||||
for pattern, (color, bold, underline) in color_mappings.items():
|
||||
for match in re.finditer(pattern, text):
|
||||
pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline))
|
||||
|
||||
# Sort matches by start position to process sequentially
|
||||
pattern_matches.sort(key=lambda x: x[0])
|
||||
|
||||
for start, end, content, color, bold, underline in pattern_matches:
|
||||
# Preserve non-matching text including spaces
|
||||
if last_pos < start:
|
||||
segment = text[last_pos:start]
|
||||
matches.append((segment, "settings_default", False, False))
|
||||
|
||||
# Append the colored segment
|
||||
matches.append((content, color, bold, underline))
|
||||
last_pos = end
|
||||
|
||||
# Preserve any trailing text
|
||||
if last_pos < len(text):
|
||||
matches.append((text[last_pos:], "settings_default", False, False))
|
||||
|
||||
return matches
|
||||
|
||||
def wrap_ansi_text(segments, wrap_width):
|
||||
"""Wraps text while preserving ANSI formatting and spaces."""
|
||||
wrapped_lines = []
|
||||
line_buffer = []
|
||||
line_length = 0
|
||||
|
||||
for text, color, bold, underline in segments:
|
||||
words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately
|
||||
|
||||
for word in words:
|
||||
word_length = len(word)
|
||||
|
||||
if line_length + word_length > wrap_width and word.strip():
|
||||
# If the word (ignoring spaces) exceeds width, wrap the line
|
||||
wrapped_lines.append(line_buffer)
|
||||
line_buffer = []
|
||||
line_length = 0
|
||||
|
||||
line_buffer.append((word, color, bold, underline))
|
||||
line_length += word_length
|
||||
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer)
|
||||
|
||||
return wrapped_lines
|
||||
|
||||
raw_lines = help_content.split("\\n") # Preserve new lines
|
||||
wrapped_help = []
|
||||
|
||||
for raw_line in raw_lines:
|
||||
color_segments = extract_ansi_segments(raw_line)
|
||||
wrapped_segments = wrap_ansi_text(color_segments, wrap_width)
|
||||
wrapped_help.extend(wrapped_segments)
|
||||
pass
|
||||
|
||||
# Trim and add ellipsis if needed
|
||||
if len(wrapped_help) > max_lines:
|
||||
wrapped_help = wrapped_help[:max_lines]
|
||||
wrapped_help[-1].append(("...", "settings_default", False, False))
|
||||
|
||||
return wrapped_help
|
||||
|
||||
|
||||
def move_highlight(old_idx, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state):
|
||||
if old_idx == state.selected_index: # No-op
|
||||
return
|
||||
|
||||
max_index = len(options) + (1 if state.show_save_option else 0) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
|
||||
|
||||
# Adjust state.start_index only when moving out of visible range
|
||||
if state.selected_index == max_index and state.show_save_option:
|
||||
pass
|
||||
elif state.selected_index < state.start_index[-1]: # Moving above the visible area
|
||||
state.start_index[-1] = state.selected_index
|
||||
elif state.selected_index >= state.start_index[-1] + visible_height: # Moving below the visible area
|
||||
state.start_index[-1] = state.selected_index - visible_height
|
||||
pass
|
||||
|
||||
# Ensure state.start_index is within bounds
|
||||
state.start_index[-1] = max(0, min(state.start_index[-1], max_index - visible_height + 1))
|
||||
|
||||
# Clear old selection
|
||||
if state.show_save_option and old_idx == max_index:
|
||||
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
|
||||
else:
|
||||
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
|
||||
|
||||
# Highlight new selection
|
||||
if state.show_save_option and state.selected_index == max_index:
|
||||
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True))
|
||||
else:
|
||||
menu_pad.chgat(state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True))
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
# Refresh pad only if scrolling is needed
|
||||
menu_pad.refresh(state.start_index[-1], 0,
|
||||
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + visible_height,
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
|
||||
|
||||
# Update help window
|
||||
transformed_path = transform_menu_path(state.menu_path)
|
||||
selected_option = options[state.selected_index] if state.selected_index < len(options) else None
|
||||
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
|
||||
help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1])
|
||||
|
||||
draw_arrows(menu_win, visible_height, max_index, state)
|
||||
|
||||
|
||||
def draw_arrows(win, visible_height, max_index, state):
|
||||
|
||||
# vh = visible_height + (1 if show_save_option else 0)
|
||||
mi = max_index - (2 if state.show_save_option else 0)
|
||||
|
||||
if visible_height < mi:
|
||||
if state.start_index[-1] > 0:
|
||||
win.addstr(3, 2, "▲", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(3, 2, " ", get_color("settings_default"))
|
||||
|
||||
if mi - state.start_index[-1] >= visible_height + (0 if state.show_save_option else 1) :
|
||||
win.addstr(visible_height + 3, 2, "▼", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
|
||||
|
||||
|
||||
def settings_menu(stdscr, interface):
|
||||
curses.update_lines_cols()
|
||||
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
menu_state.current_menu = menu["Main Menu"]
|
||||
menu_state.menu_path = ["Main Menu"]
|
||||
state.current_menu = menu["Main Menu"]
|
||||
state.menu_path = ["Main Menu"]
|
||||
|
||||
|
||||
modified_settings = {}
|
||||
|
||||
menu_state.need_redraw = True
|
||||
menu_state.show_save_option = False
|
||||
new_value_name = None
|
||||
|
||||
need_redraw = True
|
||||
state.show_save_option = False
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
options = list(menu_state.current_menu.keys())
|
||||
if(need_redraw):
|
||||
options = list(state.current_menu.keys())
|
||||
|
||||
# Determine if save option should be shown
|
||||
path = menu_state.menu_path
|
||||
menu_state.show_save_option = (
|
||||
(len(path) > 2 and ("Radio Settings" in path or "Module Settings" in path))
|
||||
or (len(path) == 2 and "User Settings" in path)
|
||||
or (len(path) == 3 and "Channels" in path)
|
||||
state.show_save_option = (
|
||||
len(state.menu_path) > 2 and ("Radio Settings" in state.menu_path or "Module Settings" in state.menu_path)
|
||||
) or (
|
||||
len(state.menu_path) == 2 and "User Settings" in state.menu_path
|
||||
) or (
|
||||
len(state.menu_path) == 3 and "Channels" in state.menu_path
|
||||
)
|
||||
|
||||
# Display the menu
|
||||
menu_win, menu_pad = display_menu()
|
||||
menu_win, menu_pad = display_menu(state)
|
||||
|
||||
if menu_win is None:
|
||||
continue # Skip if menu_win is not initialized
|
||||
need_redraw = False
|
||||
|
||||
menu_win.timeout(200) # wait up to 200 ms for a keypress (or less if key is pressed)
|
||||
# Capture user input
|
||||
key = menu_win.getch()
|
||||
if key == -1:
|
||||
continue
|
||||
|
||||
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
|
||||
max_index = len(options) + (1 if state.show_save_option else 0) - 1
|
||||
# max_help_lines = 4
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
old_selected_index = menu_state.selected_index
|
||||
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
|
||||
move_highlight(
|
||||
old_selected_index,
|
||||
options,
|
||||
menu_win,
|
||||
menu_pad,
|
||||
menu_state=menu_state,
|
||||
help_win=help_win,
|
||||
help_text=help_text,
|
||||
max_help_lines=max_help_lines,
|
||||
)
|
||||
|
||||
old_selected_index = state.selected_index
|
||||
state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
|
||||
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
old_selected_index = menu_state.selected_index
|
||||
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
|
||||
move_highlight(
|
||||
old_selected_index,
|
||||
options,
|
||||
menu_win,
|
||||
menu_pad,
|
||||
menu_state=menu_state,
|
||||
help_win=help_win,
|
||||
help_text=help_text,
|
||||
max_help_lines=max_help_lines,
|
||||
)
|
||||
old_selected_index = state.selected_index
|
||||
state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
|
||||
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
|
||||
|
||||
elif key == curses.KEY_RESIZE:
|
||||
menu_state.need_redraw = True
|
||||
need_redraw = True
|
||||
curses.update_lines_cols()
|
||||
|
||||
menu_win.erase()
|
||||
if help_win:
|
||||
help_win.erase()
|
||||
help_win.erase()
|
||||
|
||||
menu_win.refresh()
|
||||
if help_win:
|
||||
help_win.refresh()
|
||||
help_win.refresh()
|
||||
|
||||
elif key == ord("\t") and menu_state.show_save_option:
|
||||
old_selected_index = menu_state.selected_index
|
||||
menu_state.selected_index = max_index
|
||||
move_highlight(
|
||||
old_selected_index,
|
||||
options,
|
||||
menu_win,
|
||||
menu_pad,
|
||||
menu_state=menu_state,
|
||||
help_win=help_win,
|
||||
help_text=help_text,
|
||||
max_help_lines=max_help_lines,
|
||||
)
|
||||
elif key == ord("\t") and state.show_save_option:
|
||||
old_selected_index = state.selected_index
|
||||
state.selected_index = max_index
|
||||
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
|
||||
|
||||
elif key == curses.KEY_RIGHT or key == ord("\n"):
|
||||
menu_state.need_redraw = True
|
||||
menu_state.start_index.append(0)
|
||||
elif key == curses.KEY_RIGHT or key == ord('\n'):
|
||||
need_redraw = True
|
||||
state.start_index.append(0)
|
||||
menu_win.erase()
|
||||
if help_win:
|
||||
help_win.erase()
|
||||
help_win.erase()
|
||||
|
||||
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
|
||||
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, state.current_menu, selected_index, transform_menu_path(state.menu_path))
|
||||
|
||||
menu_win.refresh()
|
||||
if help_win:
|
||||
help_win.refresh()
|
||||
help_win.refresh()
|
||||
|
||||
if menu_state.show_save_option and menu_state.selected_index == len(options):
|
||||
save_changes(interface, modified_settings, menu_state)
|
||||
if state.show_save_option and state.selected_index == len(options):
|
||||
save_changes(interface, modified_settings, state)
|
||||
modified_settings.clear()
|
||||
logging.info("Changes Saved")
|
||||
|
||||
if len(menu_state.menu_path) > 1:
|
||||
menu_state.menu_path.pop()
|
||||
menu_state.current_menu = menu["Main Menu"]
|
||||
for step in menu_state.menu_path[1:]:
|
||||
menu_state.current_menu = menu_state.current_menu.get(step, {})
|
||||
menu_state.selected_index = 0
|
||||
if len(state.menu_path) > 1:
|
||||
state.menu_path.pop()
|
||||
state.current_menu = menu["Main Menu"]
|
||||
for step in state.menu_path[1:]:
|
||||
state.current_menu = state.current_menu.get(step, {})
|
||||
state.selected_index = 0
|
||||
continue
|
||||
|
||||
selected_option = options[menu_state.selected_index]
|
||||
selected_option = options[state.selected_index]
|
||||
|
||||
if selected_option == "Exit":
|
||||
break
|
||||
|
||||
elif selected_option == "Export Config File":
|
||||
|
||||
filename = get_text_input("Enter a filename for the config file", None, None)
|
||||
filename = get_text_input("Enter a filename for the config file")
|
||||
if not filename:
|
||||
logging.info("Export aborted: No filename provided.")
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue # Go back to the menu
|
||||
if not filename.lower().endswith(".yaml"):
|
||||
filename += ".yaml"
|
||||
@@ -317,15 +423,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
|
||||
if overwrite == "No":
|
||||
logging.info("Export cancelled: User chose not to overwrite.")
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue # Return to menu
|
||||
os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True)
|
||||
with open(yaml_file_path, "w", encoding="utf-8") as file:
|
||||
file.write(config_text)
|
||||
logging.info(f"Config file saved to {yaml_file_path}")
|
||||
dialog("Config File Saved:", yaml_file_path)
|
||||
menu_state.need_redraw = True
|
||||
menu_state.start_index.pop()
|
||||
dialog(stdscr, "Config File Saved:", yaml_file_path)
|
||||
state.start_index.pop()
|
||||
continue
|
||||
except PermissionError:
|
||||
logging.error(f"Permission denied: Unable to write to {yaml_file_path}")
|
||||
@@ -333,23 +438,21 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
logging.error(f"OS error while saving config: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error: {e}")
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
|
||||
elif selected_option == "Load Config File":
|
||||
|
||||
# Check if folder exists and is not empty
|
||||
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
|
||||
dialog("", " No config files found. Export a config first.")
|
||||
menu_state.need_redraw = True
|
||||
dialog(stdscr, "", " No config files found. Export a config first.")
|
||||
continue # Return to menu
|
||||
|
||||
file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))]
|
||||
|
||||
# Ensure file_list is not empty before proceeding
|
||||
if not file_list:
|
||||
dialog("", " No config files found. Export a config first.")
|
||||
menu_state.need_redraw = True
|
||||
dialog(stdscr, "", " No config files found. Export a config first.")
|
||||
continue
|
||||
|
||||
filename = get_list_input("Choose a config file", None, file_list)
|
||||
@@ -358,35 +461,35 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
|
||||
if overwrite == "Yes":
|
||||
config_import(interface, file_path)
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Config URL":
|
||||
current_value = interface.localNode.getURL()
|
||||
new_value = get_text_input(f"Config URL is currently: {current_value}", None, str)
|
||||
new_value = get_text_input(f"Config URL is currently: {current_value}")
|
||||
if new_value is not None:
|
||||
current_value = new_value
|
||||
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
|
||||
if overwrite == "Yes":
|
||||
interface.localNode.setURL(new_value)
|
||||
logging.info(f"New Config URL sent to node")
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Reboot":
|
||||
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
|
||||
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.reboot()
|
||||
logging.info(f"Node Reboot Requested by menu")
|
||||
menu_state.start_index.pop()
|
||||
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("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")
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Shutdown":
|
||||
@@ -394,79 +497,72 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.shutdown()
|
||||
logging.info(f"Node Shutdown Requested by menu")
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Factory Reset":
|
||||
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
|
||||
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.factoryReset()
|
||||
logging.info(f"Factory Reset Requested by menu")
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "App Settings":
|
||||
menu_win.clear()
|
||||
menu_win.refresh()
|
||||
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
|
||||
menu_state.current_menu = menu["Main Menu"]
|
||||
menu_state.menu_path = ["Main Menu"]
|
||||
menu_state.start_index.pop()
|
||||
menu_state.selected_index = 4
|
||||
state.menu_path.append("App Settings")
|
||||
state.menu_index.append(state.selected_index)
|
||||
json_editor(stdscr, state) # Open the App Settings menu
|
||||
state.current_menu = menu["Main Menu"]
|
||||
state.menu_path = ["Main Menu"]
|
||||
state.start_index.pop()
|
||||
state.selected_index = 4
|
||||
continue
|
||||
|
||||
field_info = menu_state.current_menu.get(selected_option)
|
||||
# need_redraw = True
|
||||
|
||||
field_info = state.current_menu.get(selected_option)
|
||||
if isinstance(field_info, tuple):
|
||||
field, current_value = field_info
|
||||
|
||||
# Transform the menu path to get the full key
|
||||
transformed_path = transform_menu_path(menu_state.menu_path)
|
||||
full_key = ".".join(transformed_path + [selected_option])
|
||||
transformed_path = transform_menu_path(state.menu_path)
|
||||
full_key = '.'.join(transformed_path + [selected_option])
|
||||
|
||||
# Fetch human-readable name from field_mapping
|
||||
human_readable_name = field_mapping.get(full_key, selected_option)
|
||||
|
||||
if selected_option in ["longName", "shortName", "isLicensed"]:
|
||||
if selected_option in ["longName", "shortName"]:
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||
)
|
||||
if selected_option in ['longName', 'shortName', 'isLicensed']:
|
||||
if selected_option in ['longName', 'shortName']:
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
elif selected_option == "isLicensed":
|
||||
new_value = get_list_input(
|
||||
f"{human_readable_name} is currently: {current_value}",
|
||||
str(current_value),
|
||||
["True", "False"],
|
||||
)
|
||||
elif selected_option == 'isLicensed':
|
||||
new_value = get_list_input(f"{human_readable_name} is currently: {current_value}", str(current_value), ["True", "False"])
|
||||
new_value = new_value == "True"
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
for option, (field, value) in menu_state.current_menu.items():
|
||||
for option, (field, value) in state.current_menu.items():
|
||||
modified_settings[option] = value
|
||||
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif selected_option in ["latitude", "longitude", "altitude"]:
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, float
|
||||
)
|
||||
elif selected_option in ['latitude', 'longitude', 'altitude']:
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
for option in ["latitude", "longitude", "altitude"]:
|
||||
if option in menu_state.current_menu:
|
||||
modified_settings[option] = menu_state.current_menu[option][1]
|
||||
for option in ['latitude', 'longitude', 'altitude']:
|
||||
if option in state.current_menu:
|
||||
modified_settings[option] = state.current_menu[option][1]
|
||||
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif selected_option == "admin_key":
|
||||
new_values = get_admin_key_input(current_value)
|
||||
new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values]
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 8: # Handle boolean type
|
||||
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
|
||||
@@ -474,138 +570,88 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
pass # Leave it as-is
|
||||
else:
|
||||
new_value = new_value == "True" or new_value is True
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
|
||||
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()
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.enum_type: # Enum field
|
||||
enum_options = {v.name: v.number for v in field.enum_type.values}
|
||||
new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys()))
|
||||
new_value = enum_options.get(new_value_name, current_value)
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 7: # Field type 7 corresponds to FIXED32
|
||||
elif field.type == 7: # Field type 7 corresponds to FIXED32
|
||||
new_value = get_fixed32_input(current_value)
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 13: # Field type 13 corresponds to UINT32
|
||||
input_type = get_input_type_for_field(field)
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||
)
|
||||
elif field.type == 13: # Field type 13 corresponds to UINT32
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else int(new_value)
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 2: # Field type 13 corresponds to INT64
|
||||
input_type = get_input_type_for_field(field)
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||
)
|
||||
elif field.type == 2: # Field type 13 corresponds to INT64
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else float(new_value)
|
||||
menu_state.start_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
else: # Handle other field types
|
||||
input_type = get_input_type_for_field(field)
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||
)
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
menu_state.start_index.pop()
|
||||
|
||||
for key in menu_state.menu_path[3:]: # Skip "Main Menu"
|
||||
state.start_index.pop()
|
||||
|
||||
for key in state.menu_path[3:]: # Skip "Main Menu"
|
||||
modified_settings = modified_settings.setdefault(key, {})
|
||||
|
||||
# For comparison, normalize enum numbers to names
|
||||
compare_value = new_value
|
||||
if field and field.enum_type and isinstance(new_value, int):
|
||||
enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
|
||||
if enum_value_descriptor:
|
||||
compare_value = enum_value_descriptor.name
|
||||
|
||||
if compare_value != current_value:
|
||||
# Save the raw protobuf number, not the name
|
||||
modified_settings[selected_option] = new_value
|
||||
# Add the new value to the appropriate level
|
||||
modified_settings[selected_option] = new_value
|
||||
|
||||
# Convert enum string to int
|
||||
if field and field.enum_type:
|
||||
enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
|
||||
new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
|
||||
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
state.current_menu[selected_option] = (field, new_value)
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
menu_state.menu_path.append(selected_option)
|
||||
menu_state.menu_index.append(menu_state.selected_index)
|
||||
menu_state.selected_index = 0
|
||||
state.current_menu = state.current_menu[selected_option]
|
||||
state.menu_path.append(selected_option)
|
||||
state.menu_index.append(state.selected_index)
|
||||
state.selected_index = 0
|
||||
|
||||
|
||||
elif key == curses.KEY_LEFT:
|
||||
|
||||
# If we are at the main menu and there are unsaved changes, prompt to save
|
||||
if len(menu_state.menu_path) == 3 and modified_settings:
|
||||
|
||||
current_section = menu_state.menu_path[-1]
|
||||
save_prompt = get_list_input(
|
||||
f"You have unsaved changes in {current_section}. Save before exiting?",
|
||||
None,
|
||||
["Yes", "No", "Cancel"],
|
||||
mandatory=True,
|
||||
)
|
||||
if save_prompt == "Cancel":
|
||||
continue # Stay in the menu without doing anything
|
||||
elif save_prompt == "Yes":
|
||||
save_changes(interface, modified_settings, menu_state)
|
||||
logging.info("Changes Saved")
|
||||
|
||||
modified_settings.clear()
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
pass
|
||||
|
||||
menu_state.need_redraw = True
|
||||
need_redraw = True
|
||||
|
||||
menu_win.erase()
|
||||
if help_win:
|
||||
help_win.erase()
|
||||
help_win.erase()
|
||||
|
||||
# max_help_lines = 4
|
||||
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
|
||||
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, state.current_menu, selected_index, transform_menu_path(state.menu_path))
|
||||
|
||||
menu_win.refresh()
|
||||
if help_win:
|
||||
help_win.refresh()
|
||||
help_win.refresh()
|
||||
|
||||
# if len(menu_state.menu_path) < 2:
|
||||
# modified_settings.clear()
|
||||
if len(state.menu_path) < 2:
|
||||
modified_settings.clear()
|
||||
|
||||
# Navigate back to the previous menu
|
||||
if len(menu_state.menu_path) > 1:
|
||||
menu_state.menu_path.pop()
|
||||
menu_state.current_menu = menu["Main Menu"]
|
||||
for step in menu_state.menu_path[1:]:
|
||||
menu_state.current_menu = menu_state.current_menu.get(step, {})
|
||||
menu_state.selected_index = menu_state.menu_index.pop()
|
||||
menu_state.start_index.pop()
|
||||
|
||||
if len(state.menu_path) > 1:
|
||||
state.menu_path.pop()
|
||||
state.current_menu = menu["Main Menu"]
|
||||
for step in state.menu_path[1:]:
|
||||
state.current_menu = state.current_menu.get(step, {})
|
||||
state.selected_index = state.menu_index.pop()
|
||||
state.start_index.pop()
|
||||
|
||||
elif key == 27: # Escape key
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
break
|
||||
|
||||
|
||||
def rebuild_menu_at_current_path(interface, menu_state):
|
||||
"""Rebuild menus from the device and re-point current_menu to the same path."""
|
||||
new_menu = generate_menu_from_protobuf(interface)
|
||||
cur = new_menu["Main Menu"]
|
||||
for step in menu_state.menu_path[1:]:
|
||||
cur = cur.get(step, {})
|
||||
menu_state.current_menu = cur
|
||||
return new_menu
|
||||
|
||||
|
||||
def set_region(interface: object) -> None:
|
||||
node = interface.getNode("^local")
|
||||
def set_region(interface):
|
||||
node = interface.getNode('^local')
|
||||
device_config = node.localConfig
|
||||
lora_descriptor = device_config.lora.DESCRIPTOR
|
||||
|
||||
@@ -615,10 +661,10 @@ 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('Select your region:', 'UNSET', regions)
|
||||
|
||||
# Convert region name to corresponding enum number
|
||||
new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found
|
||||
|
||||
node.localConfig.lora.region = new_region_number
|
||||
node.writeConfig("lora")
|
||||
node.writeConfig("lora")
|
||||
@@ -1,82 +1,27 @@
|
||||
import json
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from typing import Dict
|
||||
from contact.ui.colors import setup_colors
|
||||
|
||||
# Get the parent directory of the script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||
|
||||
# To test writting to a non-writable directory, you can uncomment the following lines:
|
||||
# mkdir /tmp/test_nonwritable
|
||||
# chmod -w /tmp/test_nonwritable
|
||||
# parent_dir = "/tmp/test_nonwritable"
|
||||
# Paths
|
||||
json_file_path = os.path.join(parent_dir, "config.json")
|
||||
log_file_path = os.path.join(parent_dir, "client.log")
|
||||
db_file_path = os.path.join(parent_dir, "client.db")
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
loaded_config = initialize_config()
|
||||
assign_config_variables(loaded_config)
|
||||
setup_colors(reinit=True)
|
||||
|
||||
|
||||
def _is_writable_dir(path: str) -> bool:
|
||||
"""
|
||||
Return True if we can create & delete a temp file in `path`.
|
||||
"""
|
||||
if not os.path.isdir(path):
|
||||
return False
|
||||
test_path = os.path.join(path, ".perm_test_tmp")
|
||||
try:
|
||||
with open(test_path, "w", encoding="utf-8") as _tmp:
|
||||
_tmp.write("ok")
|
||||
os.remove(test_path)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client") -> str:
|
||||
"""
|
||||
Choose a writable directory for config artifacts.
|
||||
"""
|
||||
if _is_writable_dir(preferred_dir):
|
||||
return preferred_dir
|
||||
|
||||
home = os.path.expanduser("~")
|
||||
fallback_dir = os.path.join(home, fallback_name)
|
||||
# Ensure the fallback exists.
|
||||
os.makedirs(fallback_dir, exist_ok=True)
|
||||
|
||||
# If *that* still isn't writable, last-ditch: use a system temp dir.
|
||||
if not _is_writable_dir(fallback_dir):
|
||||
import tempfile
|
||||
|
||||
fallback_dir = tempfile.mkdtemp(prefix="contact_client_")
|
||||
|
||||
return fallback_dir
|
||||
|
||||
|
||||
# Pick the root now.
|
||||
config_root = _get_config_root(parent_dir)
|
||||
|
||||
# Paths (derived from the chosen root)
|
||||
json_file_path = os.path.join(config_root, "config.json")
|
||||
log_file_path = os.path.join(config_root, "client.log")
|
||||
db_file_path = os.path.join(config_root, "client.db")
|
||||
node_configs_file_path = os.path.join(config_root, "node-configs/")
|
||||
|
||||
|
||||
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
|
||||
def format_json_single_line_arrays(data, indent=4):
|
||||
"""
|
||||
Formats JSON with arrays on a single line while keeping other elements properly indented.
|
||||
"""
|
||||
|
||||
def format_value(value: object, current_indent: int) -> str:
|
||||
def format_value(value, current_indent):
|
||||
if isinstance(value, dict):
|
||||
items = []
|
||||
for key, val in value.items():
|
||||
items.append(f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}')
|
||||
items.append(
|
||||
f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}'
|
||||
)
|
||||
return "{\n" + ",\n".join(items) + f"\n{' ' * (current_indent - indent)}}}"
|
||||
elif isinstance(value, list):
|
||||
return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]"
|
||||
@@ -85,9 +30,8 @@ def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) ->
|
||||
|
||||
return format_value(data, indent)
|
||||
|
||||
|
||||
# Recursive function to check and update nested dictionaries
|
||||
def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool:
|
||||
def update_dict(default, actual):
|
||||
updated = False
|
||||
for key, value in default.items():
|
||||
if key not in actual:
|
||||
@@ -98,8 +42,7 @@ def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool:
|
||||
updated = update_dict(value, actual[key]) or updated
|
||||
return updated
|
||||
|
||||
|
||||
def initialize_config() -> Dict[str, object]:
|
||||
def initialize_config():
|
||||
COLOR_CONFIG_DARK = {
|
||||
"default": ["white", "black"],
|
||||
"background": [" ", "black"],
|
||||
@@ -124,7 +67,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"settings_warning": ["red", "black"],
|
||||
"settings_note": ["green", "black"],
|
||||
"node_favorite": ["green", "black"],
|
||||
"node_ignored": ["red", "black"],
|
||||
"node_ignored": ["red", "black"]
|
||||
}
|
||||
COLOR_CONFIG_LIGHT = {
|
||||
"default": ["black", "white"],
|
||||
@@ -150,7 +93,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"settings_warning": ["red", "white"],
|
||||
"settings_note": ["green", "white"],
|
||||
"node_favorite": ["green", "white"],
|
||||
"node_ignored": ["red", "white"],
|
||||
"node_ignored": ["red", "white"]
|
||||
}
|
||||
COLOR_CONFIG_GREEN = {
|
||||
"default": ["green", "black"],
|
||||
@@ -177,20 +120,15 @@ def initialize_config() -> Dict[str, object]:
|
||||
"settings_breadcrumbs": ["green", "black"],
|
||||
"settings_warning": ["green", "black"],
|
||||
"settings_note": ["green", "black"],
|
||||
"node_favorite": ["cyan", "green"],
|
||||
"node_ignored": ["red", "black"],
|
||||
"node_favorite": ["cyan", "white"],
|
||||
"node_ignored": ["red", "white"]
|
||||
}
|
||||
default_config_variables = {
|
||||
"channel_list_16ths": "3",
|
||||
"node_list_16ths": "5",
|
||||
"single_pane_mode": "False",
|
||||
"db_file_path": db_file_path,
|
||||
"log_file_path": log_file_path,
|
||||
"node_configs_file_path": node_configs_file_path,
|
||||
"message_prefix": ">>",
|
||||
"sent_message_prefix": ">> Sent",
|
||||
"notification_symbol": "*",
|
||||
"notification_sound": "True",
|
||||
"ack_implicit_str": "[◌]",
|
||||
"ack_str": "[✓]",
|
||||
"nak_str": "[x]",
|
||||
@@ -199,7 +137,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"theme": "dark",
|
||||
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
|
||||
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
|
||||
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN,
|
||||
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN
|
||||
}
|
||||
|
||||
if not os.path.exists(json_file_path):
|
||||
@@ -216,38 +154,30 @@ def initialize_config() -> Dict[str, object]:
|
||||
|
||||
# Update the JSON file if any variables were missing
|
||||
if updated:
|
||||
formatted_json = format_json_single_line_arrays(loaded_config)
|
||||
with open(json_file_path, "w", encoding="utf-8") as json_file:
|
||||
json_file.write(formatted_json)
|
||||
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
|
||||
formatted_json = format_json_single_line_arrays(loaded_config)
|
||||
with open(json_file_path, "w", encoding="utf-8") as json_file:
|
||||
json_file.write(formatted_json)
|
||||
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
|
||||
|
||||
return loaded_config
|
||||
|
||||
|
||||
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
def assign_config_variables(loaded_config):
|
||||
# Assign values to local variables
|
||||
|
||||
global db_file_path, log_file_path, node_configs_file_path, message_prefix, sent_message_prefix
|
||||
|
||||
global db_file_path, log_file_path, message_prefix, sent_message_prefix
|
||||
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
|
||||
global node_list_16ths, channel_list_16ths, single_pane_mode
|
||||
global theme, COLOR_CONFIG
|
||||
global node_sort, notification_sound
|
||||
global node_sort
|
||||
|
||||
channel_list_16ths = loaded_config["channel_list_16ths"]
|
||||
node_list_16ths = loaded_config["node_list_16ths"]
|
||||
single_pane_mode = loaded_config["single_pane_mode"]
|
||||
db_file_path = loaded_config["db_file_path"]
|
||||
log_file_path = loaded_config["log_file_path"]
|
||||
node_configs_file_path = loaded_config.get("node_configs_file_path")
|
||||
message_prefix = loaded_config["message_prefix"]
|
||||
sent_message_prefix = loaded_config["sent_message_prefix"]
|
||||
notification_symbol = loaded_config["notification_symbol"]
|
||||
notification_sound = loaded_config["notification_sound"]
|
||||
ack_implicit_str = loaded_config["ack_implicit_str"]
|
||||
ack_str = loaded_config["ack_str"]
|
||||
nak_str = loaded_config["nak_str"]
|
||||
ack_unknown_str = loaded_config["ack_unknown_str"]
|
||||
node_sort = loaded_config["node_sort"]
|
||||
theme = loaded_config["theme"]
|
||||
if theme == "dark":
|
||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
|
||||
@@ -255,6 +185,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
|
||||
elif theme == "green":
|
||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
|
||||
node_sort = loaded_config["node_sort"]
|
||||
|
||||
|
||||
# Call the function when the script is imported
|
||||
@@ -263,14 +194,13 @@ assign_config_variables(loaded_config)
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
filename="default_config.log",
|
||||
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
filename="default_config.log",
|
||||
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
print("\nLoaded Configuration:")
|
||||
print(f"Database File Path: {db_file_path}")
|
||||
print(f"Log File Path: {log_file_path}")
|
||||
print(f"Configs File Path: {node_configs_file_path}")
|
||||
print(f"Message Prefix: {message_prefix}")
|
||||
print(f"Sent Message Prefix: {sent_message_prefix}")
|
||||
print(f"Notification Symbol: {notification_symbol}")
|
||||
@@ -278,4 +208,4 @@ if __name__ == "__main__":
|
||||
print(f"ACK String: {ack_str}")
|
||||
print(f"NAK String: {nak_str}")
|
||||
print(f"ACK Unknown String: {ack_unknown_str}")
|
||||
print(f"Color Config: {COLOR_CONFIG}")
|
||||
print(f"Color Config: {COLOR_CONFIG}")
|
||||
@@ -1,63 +1,43 @@
|
||||
import curses
|
||||
from contact.ui.colors import get_color
|
||||
from contact.utilities.singleton import menu_state, ui_state
|
||||
|
||||
def dialog(stdscr, title, message):
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
def dialog(title: str, message: str) -> None:
|
||||
"""Display a dialog with a title and message."""
|
||||
|
||||
previous_window = ui_state.current_window
|
||||
ui_state.current_window = 4
|
||||
|
||||
curses.update_lines_cols()
|
||||
height, width = curses.LINES, curses.COLS
|
||||
|
||||
# Parse message into lines and calculate dimensions
|
||||
# Calculate dialog dimensions
|
||||
max_line_lengh = 0
|
||||
message_lines = message.splitlines()
|
||||
max_line_length = max(len(l) for l in message_lines)
|
||||
for l in message_lines:
|
||||
max_line_length = max(len(l), max_line_lengh)
|
||||
dialog_width = max(len(title) + 4, max_line_length + 4)
|
||||
dialog_height = len(message_lines) + 4
|
||||
x = (width - dialog_width) // 2
|
||||
y = (height - dialog_height) // 2
|
||||
|
||||
def draw_window():
|
||||
win.erase()
|
||||
win.bkgd(get_color("background"))
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.border(0)
|
||||
|
||||
win.addstr(0, 2, title, get_color("settings_default"))
|
||||
|
||||
for i, line in enumerate(message_lines):
|
||||
msg_x = (dialog_width - len(line)) // 2
|
||||
win.addstr(2 + i, msg_x, line, get_color("settings_default"))
|
||||
|
||||
ok_text = " Ok "
|
||||
win.addstr(
|
||||
dialog_height - 2,
|
||||
(dialog_width - len(ok_text)) // 2,
|
||||
ok_text,
|
||||
get_color("settings_default", reverse=True),
|
||||
)
|
||||
|
||||
win.refresh()
|
||||
|
||||
# Create dialog window
|
||||
win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||
draw_window()
|
||||
win.bkgd(get_color("background"))
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.border(0)
|
||||
|
||||
# Add title
|
||||
win.addstr(0, 2, title, get_color("settings_default"))
|
||||
|
||||
# Add message
|
||||
for i, l in enumerate(message_lines):
|
||||
win.addstr(2 + i, 2, l, get_color("settings_default"))
|
||||
|
||||
# Add button
|
||||
win.addstr(dialog_height - 2, (dialog_width - 4) // 2, " Ok ", get_color("settings_default", reverse=True))
|
||||
|
||||
# Refresh dialog window
|
||||
win.refresh()
|
||||
|
||||
# Get user input
|
||||
while True:
|
||||
win.timeout(200)
|
||||
char = win.getch()
|
||||
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
draw_window()
|
||||
|
||||
if char in (curses.KEY_ENTER, 10, 13, 32, 27): # Enter, space, Esc
|
||||
# Close dialog with enter, space, or esc
|
||||
if char in(curses.KEY_ENTER, 10, 13, 32, 27):
|
||||
win.erase()
|
||||
win.refresh()
|
||||
ui_state.current_window = previous_window
|
||||
return
|
||||
|
||||
if char == -1:
|
||||
continue
|
||||
|
||||
@@ -1,44 +1,29 @@
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
|
||||
from typing import Any, Union, Dict
|
||||
|
||||
from google.protobuf.message import Message
|
||||
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
|
||||
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2
|
||||
import logging
|
||||
import base64
|
||||
import os
|
||||
|
||||
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:
|
||||
def encode_if_bytes(value):
|
||||
"""Encode byte values to base64 string."""
|
||||
if isinstance(value, bytes):
|
||||
return base64.b64encode(value).decode("utf-8")
|
||||
return base64.b64encode(value).decode('utf-8')
|
||||
return value
|
||||
|
||||
|
||||
def extract_fields(
|
||||
message_instance: Message, current_config: Union[Message, Dict[str, Any], None] = None
|
||||
) -> Dict[str, Any]:
|
||||
def extract_fields(message_instance, current_config=None):
|
||||
if isinstance(current_config, dict): # Handle dictionaries
|
||||
return {key: (None, encode_if_bytes(current_config.get(key, "Not Set"))) for key in current_config}
|
||||
|
||||
|
||||
if not hasattr(message_instance, "DESCRIPTOR"):
|
||||
return {}
|
||||
|
||||
|
||||
menu = {}
|
||||
fields = message_instance.DESCRIPTOR.fields
|
||||
for field in fields:
|
||||
skip_fields = [
|
||||
"sessionkey",
|
||||
"ChannelSettings.channel_num",
|
||||
"ChannelSettings.id",
|
||||
"LoRaConfig.ignore_incoming",
|
||||
"DeviceUIConfig.version",
|
||||
]
|
||||
skip_fields = ["sessionkey", "ChannelSettings.channel_num", "ChannelSettings.id", "LoRaConfig.ignore_incoming", "DeviceUIConfig.version"]
|
||||
if any(skip_field in field.full_name for skip_field in skip_fields):
|
||||
continue
|
||||
|
||||
@@ -62,11 +47,7 @@ def extract_fields(
|
||||
menu[field.name] = (field, encode_if_bytes(current_value))
|
||||
return menu
|
||||
|
||||
|
||||
def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
"""
|
||||
Builds the full settings menu structure from the protobuf definitions.
|
||||
"""
|
||||
def generate_menu_from_protobuf(interface):
|
||||
menu_structure = {"Main Menu": {}}
|
||||
|
||||
# Add User Settings
|
||||
@@ -78,7 +59,7 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
menu_structure["Main Menu"]["User Settings"] = {
|
||||
"longName": (None, current_user_config.get("longName", "Not Set")),
|
||||
"shortName": (None, current_user_config.get("shortName", "Not Set")),
|
||||
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
|
||||
"isLicensed": (None, current_user_config.get("isLicensed", "False"))
|
||||
}
|
||||
else:
|
||||
logging.info("User settings not found in Node Info")
|
||||
@@ -106,7 +87,7 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
position_data = {
|
||||
"latitude": (None, current_node_info["position"].get("latitude", 0.0)),
|
||||
"longitude": (None, current_node_info["position"].get("longitude", 0.0)),
|
||||
"altitude": (None, current_node_info["position"].get("altitude", 0)),
|
||||
"altitude": (None, current_node_info["position"].get("altitude", 0))
|
||||
}
|
||||
|
||||
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
|
||||
@@ -125,22 +106,20 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
module = module_config_pb2.ModuleConfig()
|
||||
current_module_config = interface.localNode.moduleConfig if interface else None
|
||||
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
|
||||
|
||||
|
||||
# Add App Settings
|
||||
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
|
||||
|
||||
# Additional settings options
|
||||
menu_structure["Main Menu"].update(
|
||||
{
|
||||
"Export Config File": None,
|
||||
"Load Config File": None,
|
||||
"Config URL": None,
|
||||
"Reboot": None,
|
||||
"Reset Node DB": None,
|
||||
"Shutdown": None,
|
||||
"Factory Reset": None,
|
||||
"Exit": None,
|
||||
}
|
||||
)
|
||||
menu_structure["Main Menu"].update({
|
||||
"Export Config File": None,
|
||||
"Load Config File": None,
|
||||
"Config URL": None,
|
||||
"Reboot": None,
|
||||
"Reset Node DB": None,
|
||||
"Shutdown": None,
|
||||
"Factory Reset": None,
|
||||
"Exit": None
|
||||
})
|
||||
|
||||
return menu_structure
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
import curses
|
||||
import re
|
||||
from unicodedata import east_asian_width
|
||||
|
||||
from contact.ui.colors import get_color
|
||||
from contact.utilities.control_utils import transform_menu_path
|
||||
from typing import Any, Optional, List, Dict
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
|
||||
|
||||
def get_node_color(node_index: int, reverse: bool = False):
|
||||
node_num = ui_state.node_list[node_index]
|
||||
node = interface_state.interface.nodesByNum.get(node_num, {})
|
||||
if node.get("isFavorite"):
|
||||
return get_color("node_favorite", reverse=reverse)
|
||||
elif node.get("isIgnored"):
|
||||
return get_color("node_ignored", reverse=reverse)
|
||||
return get_color("settings_default", reverse=reverse)
|
||||
|
||||
|
||||
# Aliases
|
||||
Segment = tuple[str, str, bool, bool]
|
||||
WrappedLine = List[Segment]
|
||||
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
save_option = "Save Changes"
|
||||
MIN_HEIGHT_FOR_HELP = 20
|
||||
|
||||
|
||||
def move_highlight(
|
||||
old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any
|
||||
) -> None:
|
||||
|
||||
show_save_option = None
|
||||
start_index = [0]
|
||||
help_text = None
|
||||
max_help_lines = 0
|
||||
help_win = None
|
||||
|
||||
if "help_win" in kwargs:
|
||||
help_win = kwargs["help_win"]
|
||||
|
||||
if "menu_state" in kwargs:
|
||||
new_idx = kwargs["menu_state"].selected_index
|
||||
show_save_option = kwargs["menu_state"].show_save_option
|
||||
start_index = kwargs["menu_state"].start_index
|
||||
transformed_path = transform_menu_path(kwargs["menu_state"].menu_path)
|
||||
else:
|
||||
new_idx = kwargs["selected_index"]
|
||||
transformed_path = []
|
||||
|
||||
if "help_text" in kwargs:
|
||||
help_text = kwargs["help_text"]
|
||||
|
||||
if "max_help_lines" in kwargs:
|
||||
max_help_lines = kwargs["max_help_lines"]
|
||||
if old_idx == new_idx: # No-op
|
||||
return
|
||||
|
||||
max_index = len(options) + (1 if show_save_option else 0) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)
|
||||
|
||||
# Adjust menu_state.start_index only when moving out of visible range
|
||||
if new_idx == max_index and show_save_option:
|
||||
pass
|
||||
elif new_idx < start_index[-1]: # Moving above the visible area
|
||||
start_index[-1] = new_idx
|
||||
elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area
|
||||
start_index[-1] = new_idx - visible_height
|
||||
|
||||
# Ensure menu_state.start_index is within bounds
|
||||
start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1))
|
||||
|
||||
# Clear old selection
|
||||
if show_save_option and old_idx == max_index:
|
||||
win_h, win_w = menu_win.getmaxyx()
|
||||
menu_win.chgat(win_h - 2, (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save"))
|
||||
else:
|
||||
menu_pad.chgat(
|
||||
old_idx,
|
||||
0,
|
||||
menu_pad.getmaxyx()[1],
|
||||
(
|
||||
get_color("settings_sensitive")
|
||||
if options[old_idx] in sensitive_settings
|
||||
else get_color("settings_default")
|
||||
),
|
||||
)
|
||||
|
||||
# Highlight new selection
|
||||
if show_save_option and new_idx == max_index:
|
||||
win_h, win_w = menu_win.getmaxyx()
|
||||
menu_win.chgat(
|
||||
win_h - 2,
|
||||
(win_w - len(save_option)) // 2,
|
||||
len(save_option),
|
||||
get_color("settings_save", reverse=True),
|
||||
)
|
||||
else:
|
||||
menu_pad.chgat(
|
||||
new_idx,
|
||||
0,
|
||||
menu_pad.getmaxyx()[1],
|
||||
(
|
||||
get_color("settings_sensitive", reverse=True)
|
||||
if options[new_idx] in sensitive_settings
|
||||
else get_color("settings_default", reverse=True)
|
||||
),
|
||||
)
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
# Refresh pad only if scrolling is needed
|
||||
menu_pad.refresh(
|
||||
start_index[-1],
|
||||
0,
|
||||
menu_win.getbegyx()[0] + 3,
|
||||
menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + visible_height,
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
|
||||
)
|
||||
|
||||
# Update help window only if help_text is populated
|
||||
selected_option = options[new_idx] if new_idx < len(options) else None
|
||||
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
|
||||
if help_win:
|
||||
win_h, win_w = menu_win.getmaxyx()
|
||||
help_win = update_help_window(
|
||||
help_win,
|
||||
help_text,
|
||||
transformed_path,
|
||||
selected_option,
|
||||
max_help_lines,
|
||||
win_w,
|
||||
help_y,
|
||||
menu_win.getbegyx()[1],
|
||||
)
|
||||
|
||||
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)
|
||||
|
||||
|
||||
def draw_arrows(
|
||||
win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool
|
||||
) -> None:
|
||||
|
||||
mi = max_index - (2 if show_save_option else 0)
|
||||
|
||||
if visible_height < mi:
|
||||
if start_index[-1] > 0:
|
||||
win.addstr(3, 2, "▲", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(3, 2, " ", get_color("settings_default"))
|
||||
|
||||
if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1):
|
||||
win.addstr(visible_height + 3, 2, "▼", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
|
||||
|
||||
|
||||
def update_help_window(
|
||||
help_win: object, # curses window or None
|
||||
help_text: Dict[str, str],
|
||||
transformed_path: List[str],
|
||||
selected_option: Optional[str],
|
||||
max_help_lines: int,
|
||||
width: int,
|
||||
help_y: int,
|
||||
help_x: int,
|
||||
) -> object: # returns a curses window
|
||||
"""Handles rendering the help window consistently."""
|
||||
|
||||
if curses.LINES < MIN_HEIGHT_FOR_HELP:
|
||||
return None
|
||||
|
||||
# Clamp target position and width to the current terminal size
|
||||
help_x = max(0, help_x)
|
||||
help_y = max(0, help_y)
|
||||
# Ensure requested width fits on screen from help_x
|
||||
max_w_from_x = max(1, curses.COLS - help_x)
|
||||
safe_width = min(width, max_w_from_x)
|
||||
# Always leave a minimal border area; enforce a minimum usable width of 3
|
||||
safe_width = max(3, safe_width)
|
||||
|
||||
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_width, max_help_lines)
|
||||
|
||||
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
|
||||
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
|
||||
|
||||
# Re-clamp Y to keep the window visible
|
||||
if help_y + help_height > curses.LINES:
|
||||
help_y = max(0, curses.LINES - help_height)
|
||||
|
||||
# If width would overflow the screen, shrink it
|
||||
if help_x + safe_width > curses.COLS:
|
||||
safe_width = max(3, curses.COLS - help_x)
|
||||
|
||||
# Create or update the help window
|
||||
if help_win is None:
|
||||
help_win = curses.newwin(help_height, safe_width, help_y, help_x)
|
||||
else:
|
||||
help_win.erase()
|
||||
help_win.refresh()
|
||||
help_win.resize(help_height, safe_width)
|
||||
try:
|
||||
help_win.mvwin(help_y, help_x)
|
||||
except curses.error:
|
||||
# If moving fails due to edge conditions, pin to (0,0) as a fallback
|
||||
help_y = 0
|
||||
help_x = 0
|
||||
help_win.mvwin(help_y, help_x)
|
||||
|
||||
help_win.bkgd(get_color("background"))
|
||||
help_win.attrset(get_color("window_frame"))
|
||||
help_win.border()
|
||||
|
||||
for idx, line_segments in enumerate(wrapped_help):
|
||||
x_pos = 2 # Start after border
|
||||
for text, color, bold, underline in line_segments:
|
||||
try:
|
||||
attr = get_color(color, bold=bold, underline=underline)
|
||||
help_win.addstr(1 + idx, x_pos, text, attr)
|
||||
x_pos += len(text)
|
||||
except curses.error:
|
||||
pass # Prevent crashes
|
||||
|
||||
help_win.refresh()
|
||||
return help_win
|
||||
|
||||
|
||||
def get_wrapped_help_text(
|
||||
help_text: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], width: int, max_lines: int
|
||||
) -> List[WrappedLine]:
|
||||
"""Fetches and formats help text for display, ensuring it fits within the allowed lines."""
|
||||
|
||||
full_help_key = ".".join(transformed_path + [selected_option]) if selected_option else None
|
||||
help_content = help_text.get(full_help_key, "No help available.")
|
||||
|
||||
wrap_width = max(width - 6, 10) # Ensure a valid wrapping width
|
||||
|
||||
# Color replacements
|
||||
color_mappings = {
|
||||
r"\[warning\](.*?)\[/warning\]": ("settings_warning", True, False), # Red for warnings
|
||||
r"\[note\](.*?)\[/note\]": ("settings_note", True, False), # Green for notes
|
||||
r"\[underline\](.*?)\[/underline\]": ("settings_default", False, True), # Underline
|
||||
r"\\033\[31m(.*?)\\033\[0m": ("settings_warning", True, False), # Red text
|
||||
r"\\033\[32m(.*?)\\033\[0m": ("settings_note", True, False), # Green text
|
||||
r"\\033\[4m(.*?)\\033\[0m": ("settings_default", False, True), # Underline
|
||||
}
|
||||
|
||||
def extract_ansi_segments(text: str) -> List[Segment]:
|
||||
"""Extracts and replaces ANSI color codes, ensuring spaces are preserved."""
|
||||
matches = []
|
||||
last_pos = 0
|
||||
pattern_matches = []
|
||||
|
||||
# Find all matches and store their positions
|
||||
for pattern, (color, bold, underline) in color_mappings.items():
|
||||
for match in re.finditer(pattern, text):
|
||||
pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline))
|
||||
|
||||
# Sort matches by start position to process sequentially
|
||||
pattern_matches.sort(key=lambda x: x[0])
|
||||
|
||||
for start, end, content, color, bold, underline in pattern_matches:
|
||||
# Preserve non-matching text including spaces
|
||||
if last_pos < start:
|
||||
segment = text[last_pos:start]
|
||||
matches.append((segment, "settings_default", False, False))
|
||||
|
||||
# Append the colored segment
|
||||
matches.append((content, color, bold, underline))
|
||||
last_pos = end
|
||||
|
||||
# Preserve any trailing text
|
||||
if last_pos < len(text):
|
||||
matches.append((text[last_pos:], "settings_default", False, False))
|
||||
|
||||
return matches
|
||||
|
||||
def wrap_ansi_text(segments: List[Segment], wrap_width: int) -> List[WrappedLine]:
|
||||
"""Wraps text while preserving ANSI formatting and spaces."""
|
||||
wrapped_lines = []
|
||||
line_buffer = []
|
||||
line_length = 0
|
||||
|
||||
for text, color, bold, underline in segments:
|
||||
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
|
||||
|
||||
for word in words:
|
||||
word_length = len(word)
|
||||
|
||||
if line_length + word_length > wrap_width and word.strip():
|
||||
# If the word (ignoring spaces) exceeds width, wrap the line
|
||||
wrapped_lines.append(line_buffer)
|
||||
line_buffer = []
|
||||
line_length = 0
|
||||
|
||||
line_buffer.append((word, color, bold, underline))
|
||||
line_length += word_length
|
||||
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer)
|
||||
|
||||
return wrapped_lines
|
||||
|
||||
raw_lines = help_content.split("\\n") # Preserve new lines
|
||||
wrapped_help = []
|
||||
|
||||
for raw_line in raw_lines:
|
||||
color_segments = extract_ansi_segments(raw_line)
|
||||
wrapped_segments = wrap_ansi_text(color_segments, wrap_width)
|
||||
wrapped_help.extend(wrapped_segments)
|
||||
pass
|
||||
|
||||
# Trim and add ellipsis if needed
|
||||
if len(wrapped_help) > max_lines:
|
||||
wrapped_help = wrapped_help[:max_lines]
|
||||
wrapped_help[-1].append(("...", "settings_default", False, False))
|
||||
|
||||
return wrapped_help
|
||||
|
||||
|
||||
def text_width(text: str) -> int:
|
||||
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
|
||||
|
||||
|
||||
def wrap_text(text: str, wrap_width: int) -> List[str]:
|
||||
"""Wraps text while preserving spaces and breaking long words."""
|
||||
|
||||
whitespace = "\t\n\x0b\x0c\r "
|
||||
whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(" "))
|
||||
text = text.translate(whitespace_trans)
|
||||
|
||||
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
|
||||
wrapped_lines = []
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
margin = 2 # Left and right margin
|
||||
wrap_width -= margin
|
||||
|
||||
for word in words:
|
||||
word_length = text_width(word)
|
||||
|
||||
if word_length > wrap_width: # Break long words
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer.strip())
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
for i in range(0, word_length, wrap_width):
|
||||
wrapped_lines.append(word[i : i + wrap_width])
|
||||
continue
|
||||
|
||||
if line_length + word_length > wrap_width and word.strip():
|
||||
wrapped_lines.append(line_buffer.strip())
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
|
||||
line_buffer += word
|
||||
line_length += word_length
|
||||
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer.strip())
|
||||
|
||||
return wrapped_lines
|
||||
|
||||
|
||||
def move_main_highlight(
|
||||
old_idx: int, new_idx, options: List[str], menu_win: curses.window, menu_pad: curses.window, ui_state: object
|
||||
) -> None:
|
||||
|
||||
if old_idx == new_idx: # No-op
|
||||
return
|
||||
|
||||
max_index = len(options) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 2
|
||||
|
||||
if new_idx < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
|
||||
ui_state.start_index[ui_state.current_window] = new_idx
|
||||
elif new_idx >= ui_state.start_index[ui_state.current_window] + visible_height: # Moving below the visible area
|
||||
ui_state.start_index[ui_state.current_window] = new_idx - visible_height + 1
|
||||
|
||||
# Ensure start_index is within bounds
|
||||
ui_state.start_index[ui_state.current_window] = max(
|
||||
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
|
||||
)
|
||||
|
||||
highlight_line(menu_win, menu_pad, old_idx, new_idx, visible_height)
|
||||
|
||||
if ui_state.current_window == 0: # hack to fix max_index
|
||||
max_index += 1
|
||||
|
||||
draw_main_arrows(menu_win, max_index, window=ui_state.current_window)
|
||||
menu_win.refresh()
|
||||
|
||||
|
||||
def highlight_line(
|
||||
menu_win: curses.window, menu_pad: curses.window, old_idx: int, new_idx: int, visible_height: int
|
||||
) -> None:
|
||||
|
||||
if ui_state.current_window == 0:
|
||||
color_old = (
|
||||
get_color("channel_selected") if old_idx == ui_state.selected_channel else get_color("channel_list")
|
||||
)
|
||||
color_new = get_color("channel_list", reverse=True) if True else get_color("channel_list", reverse=True)
|
||||
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, color_old)
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
|
||||
|
||||
elif ui_state.current_window == 2:
|
||||
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
# Refresh pad only if scrolling is needed
|
||||
menu_pad.refresh(
|
||||
ui_state.start_index[ui_state.current_window],
|
||||
0,
|
||||
menu_win.getbegyx()[0] + 1,
|
||||
menu_win.getbegyx()[1] + 1,
|
||||
menu_win.getbegyx()[0] + visible_height,
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 3,
|
||||
)
|
||||
|
||||
|
||||
def draw_main_arrows(win: object, max_index: int, window: int, **kwargs) -> None:
|
||||
|
||||
height, width = win.getmaxyx()
|
||||
usable_height = height - 2
|
||||
usable_width = width - 2
|
||||
|
||||
if window == 1 and ui_state.display_log:
|
||||
if log_height := kwargs.get("log_height"):
|
||||
usable_height -= log_height - 1
|
||||
|
||||
if usable_height < max_index:
|
||||
if ui_state.start_index[window] > 0:
|
||||
win.addstr(1, usable_width, "▲", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(1, usable_width, " ", get_color("settings_default"))
|
||||
|
||||
if max_index - ui_state.start_index[window] - 1 >= usable_height:
|
||||
win.addstr(usable_height, usable_width, "▼", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(1, usable_width, " ", get_color("settings_default"))
|
||||
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
|
||||
|
||||
|
||||
def get_msg_window_lines(messages_win, packetlog_win) -> None:
|
||||
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0
|
||||
return messages_win.getmaxyx()[0] - 2 - packetlog_height
|
||||
@@ -1,9 +1,7 @@
|
||||
import curses
|
||||
from contact.ui.colors import get_color
|
||||
|
||||
|
||||
def draw_splash(stdscr: object) -> None:
|
||||
"""Draw the splash screen with a logo and connecting message."""
|
||||
def draw_splash(stdscr):
|
||||
curses.curs_set(0)
|
||||
|
||||
stdscr.clear()
|
||||
@@ -19,12 +17,11 @@ def draw_splash(stdscr: object) -> None:
|
||||
start_x2 = width // 2 - len(message_4) // 2
|
||||
start_y = height // 2 - 1
|
||||
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
|
||||
stdscr.move(start_y + 5, start_x2)
|
||||
stdscr.addstr(start_y+1, start_x-1, message_2, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y+2, start_x-2, message_3, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y+4, start_x2, message_4, get_color("splash_text"))
|
||||
|
||||
stdscr.attrset(get_color("window_frame"))
|
||||
stdscr.box()
|
||||
stdscr.refresh()
|
||||
curses.napms(500)
|
||||
curses.napms(500)
|
||||
@@ -1,46 +1,8 @@
|
||||
from typing import Any, Union, List, Dict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuState:
|
||||
menu_index: List[int] = field(default_factory=list)
|
||||
start_index: List[int] = field(default_factory=lambda: [0])
|
||||
selected_index: int = 0
|
||||
current_menu: Union[Dict[str, Any], List[Any], str, int] = field(default_factory=dict)
|
||||
menu_path: List[str] = field(default_factory=list)
|
||||
show_save_option: bool = False
|
||||
need_redraw: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatUIState:
|
||||
display_log: bool = False
|
||||
channel_list: List[str] = field(default_factory=list)
|
||||
all_messages: Dict[str, List[str]] = field(default_factory=dict)
|
||||
notifications: List[str] = field(default_factory=list)
|
||||
packet_buffer: List[str] = field(default_factory=list)
|
||||
node_list: List[str] = field(default_factory=list)
|
||||
selected_channel: int = 0
|
||||
selected_message: int = 0
|
||||
selected_node: int = 0
|
||||
current_window: int = 0
|
||||
last_sent_time: float = 0.0
|
||||
last_traceroute_time: float = 0.0
|
||||
|
||||
selected_index: int = 0
|
||||
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
|
||||
show_save_option: bool = False
|
||||
menu_path: List[str] = field(default_factory=list)
|
||||
single_pane_mode: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterfaceState:
|
||||
interface: Any = None
|
||||
myNodeNum: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppState:
|
||||
lock: Any = None
|
||||
def __init__(self):
|
||||
self.menu_index = [] # Row we left the previous menus
|
||||
self.start_index = [0] # Row to start the menu if it doesn't all fit
|
||||
self.selected_index = 0 # Selected Row
|
||||
self.current_menu = {} # Contents of the current menu
|
||||
self.menu_path = [] # Menu Path
|
||||
self.show_save_option = False
|
||||
@@ -1,27 +1,16 @@
|
||||
import os
|
||||
import json
|
||||
import curses
|
||||
from typing import Any, List, Dict
|
||||
|
||||
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
|
||||
import contact.ui.default_config as config
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows
|
||||
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.singleton import menu_state
|
||||
|
||||
|
||||
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||
max_help_lines = 6
|
||||
width = 80
|
||||
save_option = "Save Changes"
|
||||
sensitive_settings = []
|
||||
|
||||
def edit_color_pair(key, current_value):
|
||||
|
||||
# Compute an effective width that fits the current terminal
|
||||
def get_effective_width() -> int:
|
||||
# Leave space for borders; ensure a sane minimum
|
||||
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
|
||||
|
||||
|
||||
def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
|
||||
"""
|
||||
Allows the user to select a foreground and background color for a key.
|
||||
"""
|
||||
@@ -31,17 +20,15 @@ def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
|
||||
|
||||
return [fg_color, bg_color]
|
||||
|
||||
def edit_value(key, current_value, state):
|
||||
|
||||
def edit_value(key: str, current_value: str) -> str:
|
||||
|
||||
w = get_effective_width()
|
||||
height = 10
|
||||
input_width = w - 16 # Allow space for "New Value: "
|
||||
input_width = width - 16 # Allow space for "New Value: "
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = max(0, (curses.COLS - w) // 2)
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create a centered window
|
||||
edit_win = curses.newwin(height, w, start_y, start_x)
|
||||
edit_win = curses.newwin(height, width, start_y, start_x)
|
||||
edit_win.bkgd(get_color("background"))
|
||||
edit_win.attrset(get_color("window_frame"))
|
||||
edit_win.border()
|
||||
@@ -50,8 +37,8 @@ def edit_value(key: str, current_value: str) -> str:
|
||||
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
|
||||
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
|
||||
|
||||
wrap_width = w - 4 # Account for border and padding
|
||||
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)]
|
||||
wrap_width = width - 4 # Account for border and padding
|
||||
wrapped_lines = [current_value[i:i+wrap_width] for i in range(0, len(current_value), wrap_width)]
|
||||
|
||||
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
|
||||
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
|
||||
@@ -61,23 +48,12 @@ def edit_value(key: str, current_value: str) -> str:
|
||||
# Handle theme selection dynamically
|
||||
if key == "theme":
|
||||
# Load theme names dynamically from the JSON
|
||||
theme_options = [
|
||||
k.split("_", 2)[2].lower() for k in config.loaded_config.keys() if k.startswith("COLOR_CONFIG")
|
||||
]
|
||||
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
|
||||
return get_list_input("Select Theme", current_value, theme_options)
|
||||
|
||||
elif key == "node_sort":
|
||||
sort_options = ["lastHeard", "name", "hops"]
|
||||
sort_options = ['lastHeard', 'name', 'hops']
|
||||
return get_list_input("Sort By", current_value, sort_options)
|
||||
|
||||
elif key == "notification_sound":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input("Notification Sound", current_value, sound_options)
|
||||
|
||||
elif key == "single_pane_mode":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input("Single-Pane Mode", current_value, sound_options)
|
||||
|
||||
# Standard Input Mode (Scrollable)
|
||||
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
||||
curses.curs_set(1)
|
||||
@@ -86,87 +62,63 @@ def edit_value(key: str, current_value: str) -> str:
|
||||
user_input = ""
|
||||
input_position = (7, 13) # Tuple for row and column
|
||||
row, col = input_position # Unpack tuple
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
curses.update_lines_cols()
|
||||
menu_state.need_redraw = False
|
||||
|
||||
# Re-create the window to fully reset state
|
||||
edit_win = curses.newwin(height, w, start_y, start_x)
|
||||
edit_win.timeout(200)
|
||||
edit_win.bkgd(get_color("background"))
|
||||
edit_win.attrset(get_color("window_frame"))
|
||||
edit_win.border()
|
||||
|
||||
# Redraw static content
|
||||
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
|
||||
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
|
||||
for i, line in enumerate(wrapped_lines[:4]):
|
||||
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
|
||||
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
||||
|
||||
visible_text = user_input[scroll_offset : scroll_offset + input_width]
|
||||
edit_win.addstr(row, col, " " * input_width, get_color("settings_default"))
|
||||
edit_win.addstr(row, col, visible_text, get_color("settings_default"))
|
||||
visible_text = user_input[scroll_offset:scroll_offset + input_width] # Only show what fits
|
||||
edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
|
||||
edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
|
||||
edit_win.refresh()
|
||||
|
||||
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width))
|
||||
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
|
||||
key = edit_win.get_wch()
|
||||
|
||||
try:
|
||||
key = edit_win.get_wch()
|
||||
except curses.error:
|
||||
continue # window not ready — skip this loop
|
||||
|
||||
if key in (chr(27), curses.KEY_LEFT):
|
||||
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
|
||||
curses.curs_set(0)
|
||||
return current_value
|
||||
|
||||
return current_value # Exit without returning a value
|
||||
|
||||
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
break
|
||||
|
||||
elif key in (curses.KEY_BACKSPACE, chr(127)):
|
||||
if user_input:
|
||||
elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
|
||||
if user_input: # Only process if there's something to delete
|
||||
user_input = user_input[:-1]
|
||||
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
|
||||
scroll_offset -= 1
|
||||
scroll_offset -= 1 # Move back if text is shorter than scrolled area
|
||||
else:
|
||||
if isinstance(key, str):
|
||||
user_input += key
|
||||
else:
|
||||
user_input += chr(key)
|
||||
|
||||
if len(user_input) > input_width:
|
||||
if len(user_input) > input_width: # Scroll if input exceeds visible area
|
||||
scroll_offset += 1
|
||||
|
||||
curses.curs_set(0)
|
||||
return user_input if user_input else current_value
|
||||
|
||||
|
||||
def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
def display_menu(state):
|
||||
"""
|
||||
Render the configuration menu with a Save button directly added to the window.
|
||||
"""
|
||||
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
|
||||
num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
|
||||
|
||||
# Determine menu items based on the type of current_menu
|
||||
if isinstance(menu_state.current_menu, dict):
|
||||
options = list(menu_state.current_menu.keys())
|
||||
elif isinstance(menu_state.current_menu, list):
|
||||
options = [f"[{i}]" for i in range(len(menu_state.current_menu))]
|
||||
if isinstance(state.current_menu, dict):
|
||||
options = list(state.current_menu.keys())
|
||||
elif isinstance(state.current_menu, list):
|
||||
options = [f"[{i}]" for i in range(len(state.current_menu))]
|
||||
else:
|
||||
options = [] # Fallback in case of unexpected data types
|
||||
|
||||
# Calculate dynamic dimensions for the menu
|
||||
max_menu_height = curses.LINES
|
||||
menu_height = min(max_menu_height, num_items + 5)
|
||||
menu_height = min(max_menu_height, num_items + 5)
|
||||
num_items = len(options)
|
||||
w = get_effective_width()
|
||||
start_y = (curses.LINES - menu_height) // 2
|
||||
start_x = max(0, (curses.COLS - w) // 2)
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create the window
|
||||
menu_win = curses.newwin(menu_height, w, start_y, start_x)
|
||||
menu_win = curses.newwin(menu_height, width, start_y, start_x)
|
||||
menu_win.erase()
|
||||
menu_win.bkgd(get_color("background"))
|
||||
menu_win.attrset(get_color("window_frame"))
|
||||
@@ -174,68 +126,114 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
menu_win.keypad(True)
|
||||
|
||||
# Create the pad for scrolling
|
||||
menu_pad = curses.newpad(num_items + 1, w - 8)
|
||||
menu_pad = curses.newpad(num_items + 1, width - 8)
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
# Display the menu path
|
||||
header = " > ".join(menu_state.menu_path)
|
||||
if len(header) > w - 4:
|
||||
header = header[: w - 7] + "..."
|
||||
header = " > ".join(state.menu_path)
|
||||
if len(header) > width - 4:
|
||||
header = header[:width - 7] + "..."
|
||||
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
|
||||
|
||||
# Populate the pad with menu options
|
||||
for idx, key in enumerate(options):
|
||||
value = (
|
||||
menu_state.current_menu[key]
|
||||
if isinstance(menu_state.current_menu, dict)
|
||||
else menu_state.current_menu[int(key.strip("[]"))]
|
||||
value = state.current_menu[key] if isinstance(state.current_menu, dict) else state.current_menu[int(key.strip("[]"))]
|
||||
display_key = f"{key}"[:width // 2 - 2]
|
||||
display_value = (
|
||||
f"{value}"[:width // 2 - 8]
|
||||
)
|
||||
display_key = f"{key}"[: w // 2 - 2]
|
||||
display_value = f"{value}"[: w // 2 - 8]
|
||||
|
||||
color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
|
||||
menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
|
||||
color = get_color("settings_default", reverse=(idx == state.selected_index))
|
||||
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
|
||||
|
||||
# Add Save button to the main window
|
||||
if menu_state.show_save_option:
|
||||
if state.show_save_option:
|
||||
save_position = menu_height - 2
|
||||
menu_win.addstr(
|
||||
save_position,
|
||||
(w - len(save_option)) // 2,
|
||||
save_option,
|
||||
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
|
||||
)
|
||||
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(state.current_menu))))
|
||||
|
||||
menu_win.refresh()
|
||||
menu_pad.refresh(
|
||||
menu_state.start_index[-1],
|
||||
0,
|
||||
menu_win.getbegyx()[0] + 3,
|
||||
menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
|
||||
state.start_index[-1], 0,
|
||||
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0),
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
|
||||
)
|
||||
|
||||
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
|
||||
max_index = num_items + (1 if state.show_save_option else 0) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
|
||||
|
||||
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
|
||||
draw_arrows(menu_win, visible_height, max_index, state)
|
||||
|
||||
return menu_win, menu_pad, options
|
||||
|
||||
|
||||
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
def move_highlight(old_idx, options, menu_win, menu_pad, state):
|
||||
if old_idx == state.selected_index: # No-op
|
||||
return
|
||||
|
||||
menu_state.selected_index = 0 # Track the selected option
|
||||
made_changes = False # Track if any changes were made
|
||||
max_index = len(options) + (1 if state.show_save_option else 0) - 1
|
||||
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
|
||||
|
||||
# Adjust state.start_index only when moving out of visible range
|
||||
if state.selected_index == max_index and state.show_save_option:
|
||||
pass
|
||||
elif state.selected_index < state.start_index[-1]: # Moving above the visible area
|
||||
state.start_index[-1] = state.selected_index
|
||||
elif state.selected_index >= state.start_index[-1] + visible_height: # Moving below the visible area
|
||||
state.start_index[-1] = state.selected_index - visible_height
|
||||
pass
|
||||
|
||||
# Ensure state.start_index is within bounds
|
||||
state.start_index[-1] = max(0, min(state.start_index[-1], max_index - visible_height + 1))
|
||||
|
||||
# Clear old selection
|
||||
if state.show_save_option and old_idx == max_index:
|
||||
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
|
||||
else:
|
||||
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
|
||||
|
||||
# Highlight new selection
|
||||
if state.show_save_option and state.selected_index == max_index:
|
||||
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True))
|
||||
else:
|
||||
menu_pad.chgat(state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True))
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
# Refresh pad only if scrolling is needed
|
||||
menu_pad.refresh(state.start_index[-1], 0,
|
||||
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + visible_height,
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
|
||||
|
||||
draw_arrows(menu_win, visible_height, max_index, state)
|
||||
|
||||
|
||||
def draw_arrows(win, visible_height, max_index, state):
|
||||
|
||||
mi = max_index - (2 if state.show_save_option else 0)
|
||||
|
||||
if visible_height < mi:
|
||||
if state.start_index[-1] > 0:
|
||||
win.addstr(3, 2, "▲", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(3, 2, " ", get_color("settings_default"))
|
||||
|
||||
if mi - state.start_index[-1] >= visible_height + (0 if state.show_save_option else 1) :
|
||||
win.addstr(visible_height + 3, 2, "▼", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
|
||||
|
||||
|
||||
def json_editor(stdscr, state):
|
||||
|
||||
state.selected_index = 0 # Track the selected option
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||
file_path = os.path.join(parent_dir, "config.json")
|
||||
|
||||
menu_state.show_save_option = True # Always show the Save button
|
||||
menu_state.help_win = None
|
||||
menu_state.help_text = {}
|
||||
state.show_save_option = True # Always show the Save button
|
||||
|
||||
# Ensure the file exists
|
||||
if not os.path.exists(file_path):
|
||||
@@ -243,167 +241,133 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
json.dump({}, f)
|
||||
|
||||
# Load JSON data
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
with open(file_path, "r") as f:
|
||||
original_data = json.load(f)
|
||||
|
||||
data = original_data # Reference to the original data
|
||||
menu_state.current_menu = data # Track the current level of the menu
|
||||
state.current_menu = data # Track the current level of the menu
|
||||
|
||||
# Render the menu
|
||||
menu_win, menu_pad, options = display_menu()
|
||||
menu_state.need_redraw = True
|
||||
menu_win, menu_pad, options = display_menu(state)
|
||||
need_redraw = True
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
menu_win, menu_pad, options = display_menu()
|
||||
if(need_redraw):
|
||||
menu_win, menu_pad, options = display_menu(state)
|
||||
menu_win.refresh()
|
||||
|
||||
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
|
||||
|
||||
menu_win.timeout(200)
|
||||
need_redraw = False
|
||||
|
||||
max_index = len(options) + (1 if state.show_save_option else 0) - 1
|
||||
key = menu_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
|
||||
old_selected_index = menu_state.selected_index
|
||||
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
|
||||
menu_state.help_win = move_highlight(
|
||||
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
|
||||
)
|
||||
old_selected_index = state.selected_index
|
||||
state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
|
||||
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
|
||||
old_selected_index = menu_state.selected_index
|
||||
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
|
||||
menu_state.help_win = move_highlight(
|
||||
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
|
||||
)
|
||||
old_selected_index = state.selected_index
|
||||
state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
|
||||
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
|
||||
|
||||
elif key == ord("\t") and menu_state.show_save_option:
|
||||
old_selected_index = menu_state.selected_index
|
||||
menu_state.selected_index = max_index
|
||||
menu_state.help_win = move_highlight(
|
||||
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
|
||||
)
|
||||
elif key == ord("\t") and state.show_save_option:
|
||||
old_selected_index = state.selected_index
|
||||
state.selected_index = max_index
|
||||
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
|
||||
|
||||
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
|
||||
|
||||
menu_state.need_redraw = True
|
||||
need_redraw = True
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
|
||||
if menu_state.selected_index < len(options): # Handle selection of a menu item
|
||||
selected_key = options[menu_state.selected_index]
|
||||
menu_state.menu_path.append(str(selected_key))
|
||||
menu_state.start_index.append(0)
|
||||
menu_state.menu_index.append(menu_state.selected_index)
|
||||
|
||||
if state.selected_index < len(options): # Handle selection of a menu item
|
||||
selected_key = options[state.selected_index]
|
||||
state.menu_path.append(str(selected_key))
|
||||
state.start_index.append(0)
|
||||
state.menu_index.append(state.selected_index)
|
||||
|
||||
# Handle nested data
|
||||
if isinstance(menu_state.current_menu, dict):
|
||||
if selected_key in menu_state.current_menu:
|
||||
selected_data = menu_state.current_menu[selected_key]
|
||||
if isinstance(state.current_menu, dict):
|
||||
if selected_key in state.current_menu:
|
||||
selected_data = state.current_menu[selected_key]
|
||||
else:
|
||||
continue # Skip invalid key
|
||||
elif isinstance(menu_state.current_menu, list):
|
||||
selected_data = menu_state.current_menu[int(selected_key.strip("[]"))]
|
||||
elif isinstance(state.current_menu, list):
|
||||
selected_data = state.current_menu[int(selected_key.strip("[]"))]
|
||||
|
||||
if isinstance(selected_data, list) and len(selected_data) == 2:
|
||||
# Edit color pair
|
||||
old = selected_data
|
||||
new_value = edit_color_pair(selected_key, selected_data)
|
||||
menu_state.menu_path.pop()
|
||||
menu_state.start_index.pop()
|
||||
menu_state.menu_index.pop()
|
||||
menu_state.current_menu[selected_key] = new_value
|
||||
if new_value != old:
|
||||
made_changes = True
|
||||
state.menu_path.pop()
|
||||
state.start_index.pop()
|
||||
state.menu_index.pop()
|
||||
state.current_menu[selected_key] = new_value
|
||||
|
||||
elif isinstance(selected_data, (dict, list)):
|
||||
# Navigate into nested data
|
||||
menu_state.current_menu = selected_data
|
||||
menu_state.selected_index = 0 # Reset the selected index
|
||||
state.current_menu = selected_data
|
||||
state.selected_index = 0 # Reset the selected index
|
||||
|
||||
else:
|
||||
# General value editing
|
||||
old = selected_data
|
||||
new_value = edit_value(selected_key, selected_data)
|
||||
menu_state.menu_path.pop()
|
||||
menu_state.menu_index.pop()
|
||||
menu_state.start_index.pop()
|
||||
menu_state.current_menu[selected_key] = new_value
|
||||
menu_state.need_redraw = True
|
||||
if new_value != old:
|
||||
made_changes = True
|
||||
|
||||
new_value = edit_value(selected_key, selected_data, state)
|
||||
state.menu_path.pop()
|
||||
state.menu_index.pop()
|
||||
state.start_index.pop()
|
||||
state.current_menu[selected_key] = new_value
|
||||
need_redraw = True
|
||||
|
||||
else:
|
||||
# Save button selected
|
||||
save_json(file_path, data)
|
||||
made_changes = False
|
||||
stdscr.refresh()
|
||||
# config.reload() # This isn't refreshing the file paths as expected
|
||||
break
|
||||
continue
|
||||
|
||||
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
|
||||
|
||||
menu_state.need_redraw = True
|
||||
need_redraw = True
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
|
||||
# menu_state.selected_index = menu_state.menu_index[-1]
|
||||
# state.selected_index = state.menu_index[-1]
|
||||
|
||||
# Navigate back in the menu
|
||||
if len(menu_state.menu_path) > 2:
|
||||
menu_state.menu_path.pop()
|
||||
menu_state.start_index.pop()
|
||||
menu_state.current_menu = data
|
||||
if len(state.menu_path) > 2:
|
||||
state.menu_path.pop()
|
||||
state.start_index.pop()
|
||||
state.current_menu = data
|
||||
|
||||
for path in menu_state.menu_path[2:]:
|
||||
menu_state.current_menu = (
|
||||
menu_state.current_menu[path]
|
||||
if isinstance(menu_state.current_menu, dict)
|
||||
else menu_state.current_menu[int(path.strip("[]"))]
|
||||
)
|
||||
for path in state.menu_path[2:]:
|
||||
state.current_menu = state.current_menu[path] if isinstance(state.current_menu, dict) else state.current_menu[int(path.strip("[]"))]
|
||||
|
||||
else:
|
||||
# Exit the editor
|
||||
if made_changes:
|
||||
save_prompt = get_list_input(
|
||||
"You have unsaved changes. Save before exiting?",
|
||||
None,
|
||||
["Yes", "No", "Cancel"],
|
||||
mandatory=True,
|
||||
)
|
||||
if save_prompt == "Cancel":
|
||||
continue # Stay in the menu without doing anything
|
||||
elif save_prompt == "Yes":
|
||||
save_json(file_path, data)
|
||||
made_changes = False
|
||||
|
||||
menu_win.clear()
|
||||
menu_win.refresh()
|
||||
|
||||
|
||||
break
|
||||
|
||||
|
||||
def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
||||
formatted_json = config.format_json_single_line_arrays(data)
|
||||
def save_json(file_path, data):
|
||||
formatted_json = format_json_single_line_arrays(data)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(formatted_json)
|
||||
setup_colors(reinit=True)
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
def main(stdscr):
|
||||
from contact.ui.ui_state import MenuState
|
||||
|
||||
if len(menu_state.menu_path) == 0:
|
||||
menu_state.menu_path = ["App Settings"] # Initialize if not set
|
||||
state = MenuState()
|
||||
if len(state.menu_path) == 0:
|
||||
state.menu_path = ["App Settings"] # Initialize if not set
|
||||
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
setup_colors()
|
||||
json_editor(stdscr, menu_state)
|
||||
|
||||
json_editor(stdscr, state)
|
||||
|
||||
if __name__ == "__main__":
|
||||
curses.wrapper(main)
|
||||
curses.wrapper(main)
|
||||
@@ -1,15 +1,11 @@
|
||||
from argparse import ArgumentParser
|
||||
import argparse
|
||||
|
||||
def setup_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
add_help=True,
|
||||
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.")
|
||||
|
||||
def setup_parser() -> ArgumentParser:
|
||||
parser = ArgumentParser(
|
||||
add_help=True,
|
||||
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.",
|
||||
)
|
||||
|
||||
connOuter = parser.add_argument_group(
|
||||
"Connection", "Optional arguments to specify a device to connect to and how."
|
||||
)
|
||||
connOuter = parser.add_argument_group('Connection', 'Optional arguments to specify a device to connect to and how.')
|
||||
conn = connOuter.add_mutually_exclusive_group()
|
||||
conn.add_argument(
|
||||
"--port",
|
||||
@@ -30,10 +26,21 @@ def setup_parser() -> ArgumentParser:
|
||||
const="localhost",
|
||||
)
|
||||
conn.add_argument(
|
||||
"--ble", "-b", help="The BLE device MAC address or name to connect to.", nargs="?", default=None, const="any"
|
||||
"--ble",
|
||||
"-b",
|
||||
help="The BLE device MAC address or name to connect to.",
|
||||
nargs="?",
|
||||
default=None,
|
||||
const="any"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true"
|
||||
"--settings",
|
||||
"--set",
|
||||
"--control",
|
||||
"-c",
|
||||
help="Launch directly into the settings",
|
||||
action="store_true"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
return parser
|
||||
@@ -1,14 +1,13 @@
|
||||
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
from typing import List
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from meshtastic import mt_config
|
||||
from meshtastic import BROADCAST_ADDR, mt_config
|
||||
from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
|
||||
|
||||
# defs are from meshtastic/python/main
|
||||
|
||||
|
||||
def 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)
|
||||
@@ -21,7 +20,6 @@ def traverseConfig(config_root, config, interface_config) -> bool:
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def splitCompoundName(comp_name: str) -> List[str]:
|
||||
"""Split compound (dot separated) preference name into parts"""
|
||||
name: List[str] = comp_name.split(".")
|
||||
@@ -30,7 +28,6 @@ def splitCompoundName(comp_name: str) -> List[str]:
|
||||
name.append(comp_name)
|
||||
return name
|
||||
|
||||
|
||||
def setPref(config, comp_name, raw_val) -> bool:
|
||||
"""Set a channel or preferences value"""
|
||||
|
||||
@@ -78,7 +75,9 @@ def setPref(config, comp_name, raw_val) -> bool:
|
||||
if e:
|
||||
val = e.number
|
||||
else:
|
||||
logging.info(f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it.")
|
||||
logging.info(
|
||||
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
|
||||
)
|
||||
logging.info(f"Choices in sorted order are:")
|
||||
names = []
|
||||
for f in enumType.values:
|
||||
@@ -123,40 +122,44 @@ def setPref(config, comp_name, raw_val) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def config_import(interface, filename):
|
||||
with open(filename, encoding="utf8") as file:
|
||||
configuration = yaml.safe_load(file)
|
||||
closeNow = True
|
||||
|
||||
interface.getNode("^local", False).beginSettingsTransaction()
|
||||
interface.getNode('^local', False).beginSettingsTransaction()
|
||||
|
||||
if "owner" in configuration:
|
||||
logging.info(f"Setting device owner to {configuration['owner']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(configuration["owner"])
|
||||
time.sleep(0.5)
|
||||
interface.getNode('^local', False).setOwner(configuration["owner"])
|
||||
|
||||
if "owner_short" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['owner_short']}")
|
||||
logging.info(
|
||||
f"Setting device owner short to {configuration['owner_short']}"
|
||||
)
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||
time.sleep(0.5)
|
||||
interface.getNode('^local', False).setOwner(
|
||||
long_name=None, short_name=configuration["owner_short"]
|
||||
)
|
||||
|
||||
if "ownerShort" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
|
||||
logging.info(
|
||||
f"Setting device owner short to {configuration['ownerShort']}"
|
||||
)
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||
time.sleep(0.5)
|
||||
interface.getNode('^local', False).setOwner(
|
||||
long_name=None, short_name=configuration["ownerShort"]
|
||||
)
|
||||
|
||||
if "channel_url" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channel_url']}")
|
||||
interface.getNode("^local").setURL(configuration["channel_url"])
|
||||
time.sleep(0.5)
|
||||
interface.getNode('^local').setURL(configuration["channel_url"])
|
||||
|
||||
if "channelUrl" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channelUrl']}")
|
||||
interface.getNode("^local").setURL(configuration["channelUrl"])
|
||||
time.sleep(0.5)
|
||||
interface.getNode('^local').setURL(configuration["channelUrl"])
|
||||
|
||||
if "location" in configuration:
|
||||
alt = 0
|
||||
@@ -175,30 +178,34 @@ def config_import(interface, filename):
|
||||
logging.info(f"Fixing longitude at {lon} degrees")
|
||||
logging.info("Setting device position")
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
time.sleep(0.5)
|
||||
|
||||
if "config" in configuration:
|
||||
localConfig = interface.getNode("^local").localConfig
|
||||
localConfig = interface.getNode('^local').localConfig
|
||||
for section in configuration["config"]:
|
||||
traverseConfig(section, configuration["config"][section], localConfig)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
traverseConfig(
|
||||
section, configuration["config"][section], localConfig
|
||||
)
|
||||
interface.getNode('^local').writeConfig(
|
||||
camel_to_snake(section)
|
||||
)
|
||||
|
||||
if "module_config" in configuration:
|
||||
moduleConfig = interface.getNode("^local").moduleConfig
|
||||
moduleConfig = interface.getNode('^local').moduleConfig
|
||||
for section in configuration["module_config"]:
|
||||
traverseConfig(
|
||||
section,
|
||||
configuration["module_config"][section],
|
||||
moduleConfig,
|
||||
)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
interface.getNode('^local').writeConfig(
|
||||
camel_to_snake(section)
|
||||
)
|
||||
|
||||
interface.getNode("^local", False).commitSettingsTransaction()
|
||||
interface.getNode('^local', False).commitSettingsTransaction()
|
||||
logging.info("Writing modified configuration to device")
|
||||
|
||||
|
||||
|
||||
def config_export(interface) -> str:
|
||||
"""used in --export-config"""
|
||||
configObj = {}
|
||||
@@ -231,7 +238,7 @@ def config_export(interface) -> str:
|
||||
if alt:
|
||||
configObj["location"]["alt"] = alt
|
||||
|
||||
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
|
||||
config = MessageToDict(interface.localNode.localConfig) #checkme - Used as a dictionary here and a string below
|
||||
if config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
@@ -242,15 +249,15 @@ def config_export(interface) -> str:
|
||||
prefs[pref] = config[pref]
|
||||
# mark base64 encoded fields as such
|
||||
if pref == "security":
|
||||
if "privateKey" in prefs[pref]:
|
||||
prefs[pref]["privateKey"] = "base64:" + prefs[pref]["privateKey"]
|
||||
if "publicKey" in prefs[pref]:
|
||||
prefs[pref]["publicKey"] = "base64:" + prefs[pref]["publicKey"]
|
||||
if "adminKey" in prefs[pref]:
|
||||
for i in range(len(prefs[pref]["adminKey"])):
|
||||
prefs[pref]["adminKey"][i] = "base64:" + prefs[pref]["adminKey"][i]
|
||||
if 'privateKey' in prefs[pref]:
|
||||
prefs[pref]['privateKey'] = 'base64:' + prefs[pref]['privateKey']
|
||||
if 'publicKey' in prefs[pref]:
|
||||
prefs[pref]['publicKey'] = 'base64:' + prefs[pref]['publicKey']
|
||||
if 'adminKey' in prefs[pref]:
|
||||
for i in range(len(prefs[pref]['adminKey'])):
|
||||
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
|
||||
if mt_config.camel_case:
|
||||
configObj["config"] = config # Identical command here and 2 lines below?
|
||||
configObj["config"] = config #Identical command here and 2 lines below?
|
||||
else:
|
||||
configObj["config"] = config
|
||||
|
||||
@@ -266,9 +273,9 @@ def config_export(interface) -> str:
|
||||
else:
|
||||
configObj["module_config"] = prefs
|
||||
|
||||
config_txt = "# start of Meshtastic configure yaml\n" # checkme - "config" (now changed to config_out)
|
||||
# was used as a string here and a Dictionary above
|
||||
config_txt = "# start of Meshtastic configure yaml\n" #checkme - "config" (now changed to config_out)
|
||||
#was used as a string here and a Dictionary above
|
||||
config_txt += yaml.dump(configObj)
|
||||
|
||||
# logging.info(config_txt)
|
||||
return config_txt
|
||||
return config_txt
|
||||
@@ -1,34 +1,31 @@
|
||||
from typing import Optional, Tuple, Dict, 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."""
|
||||
def parse_ini_file(ini_file_path):
|
||||
field_mapping = {}
|
||||
help_text = {}
|
||||
current_section = None
|
||||
|
||||
field_mapping: Dict[str, str] = {}
|
||||
help_text: Dict[str, str] = {}
|
||||
current_section: Optional[str] = None
|
||||
|
||||
with open(ini_file_path, "r", encoding="utf-8") as f:
|
||||
with open(ini_file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
if not line or line.startswith(';') or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Handle sections like [config.device]
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
current_section = line[1:-1]
|
||||
continue
|
||||
|
||||
# Parse lines like: key, "Human-readable name", "helptext"
|
||||
parts = [p.strip().strip('"') for p in line.split(",", 2)]
|
||||
parts = [p.strip().strip('"') for p in line.split(',', 2)]
|
||||
if len(parts) >= 2:
|
||||
key = parts[0]
|
||||
|
||||
# If key is 'title', map directly to the section
|
||||
if key == "title":
|
||||
if key == 'title':
|
||||
full_key = current_section
|
||||
else:
|
||||
full_key = f"{current_section}.{key}" if current_section else key
|
||||
@@ -49,18 +46,20 @@ def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
|
||||
|
||||
return field_mapping, help_text
|
||||
|
||||
|
||||
def transform_menu_path(menu_path: List[str]) -> List[str]:
|
||||
def transform_menu_path(menu_path):
|
||||
"""Applies path replacements and normalizes entries in the menu path."""
|
||||
path_replacements = {"Radio Settings": "config", "Module Settings": "module"}
|
||||
path_replacements = {
|
||||
"Radio Settings": "config",
|
||||
"Module Settings": "module"
|
||||
}
|
||||
|
||||
transformed_path: List[str] = []
|
||||
transformed_path = []
|
||||
for part in menu_path[1:]: # Skip 'Main Menu'
|
||||
# Apply fixed replacements
|
||||
part = path_replacements.get(part, part)
|
||||
|
||||
# Normalize entries like "Channel 1", "Channel 2", etc.
|
||||
if re.match(r"Channel\s+\d+", part, re.IGNORECASE):
|
||||
if re.match(r'Channel\s+\d+', part, re.IGNORECASE):
|
||||
part = "channel"
|
||||
|
||||
transformed_path.append(part)
|
||||
|
||||
@@ -2,33 +2,29 @@ import sqlite3
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union, Dict
|
||||
|
||||
from contact.utilities.utils import decimal_to_hex
|
||||
import contact.ui.default_config as config
|
||||
import contact.globals as globals
|
||||
|
||||
|
||||
from contact.utilities.singleton import ui_state, interface_state
|
||||
|
||||
|
||||
def get_table_name(channel: str) -> str:
|
||||
def get_table_name(channel):
|
||||
# Construct the table name
|
||||
table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages"
|
||||
table_name = f"{str(globals.myNodeNum)}_{channel}_messages"
|
||||
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
|
||||
return quoted_table_name
|
||||
|
||||
|
||||
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
|
||||
def save_message_to_db(channel, user_id, message_text):
|
||||
"""Save messages to the database, ensuring the table exists."""
|
||||
try:
|
||||
quoted_table_name = get_table_name(channel)
|
||||
|
||||
schema = """
|
||||
schema = '''
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
"""
|
||||
'''
|
||||
ensure_table_exists(quoted_table_name, schema)
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
@@ -36,10 +32,10 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Insert the message
|
||||
insert_query = f"""
|
||||
insert_query = f'''
|
||||
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
"""
|
||||
'''
|
||||
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
|
||||
db_connection.commit()
|
||||
|
||||
@@ -51,7 +47,7 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
|
||||
logging.error(f"Unexpected error in save_message_to_db: {e}")
|
||||
|
||||
|
||||
def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None:
|
||||
def update_ack_nak(channel, timestamp, message, ack):
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
@@ -63,7 +59,7 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
|
||||
message_text = ?
|
||||
"""
|
||||
|
||||
db_cursor.execute(update_query, (ack, str(interface_state.myNodeNum), timestamp, message))
|
||||
db_cursor.execute(update_query, (ack, str(globals.myNodeNum), timestamp, message))
|
||||
db_connection.commit()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
@@ -73,61 +69,52 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
|
||||
logging.error(f"Unexpected error in update_ack_nak: {e}")
|
||||
|
||||
|
||||
def load_messages_from_db() -> None:
|
||||
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list."""
|
||||
def load_messages_from_db():
|
||||
"""Load messages from the database for all channels and update globals.all_messages and globals.channel_list."""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
|
||||
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
|
||||
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
|
||||
db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
|
||||
tables = [row[0] for row in db_cursor.fetchall()]
|
||||
|
||||
# Iterate through each table and fetch its messages
|
||||
for table_name in tables:
|
||||
quoted_table_name = (
|
||||
f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
|
||||
)
|
||||
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")]
|
||||
quoted_table_name = f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
|
||||
table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({quoted_table_name})')]
|
||||
if "ack_type" not in table_columns:
|
||||
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
|
||||
db_cursor.execute(update_table_query)
|
||||
|
||||
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}"
|
||||
query = f'SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}'
|
||||
|
||||
try:
|
||||
# Fetch all messages from the table
|
||||
db_cursor.execute(query)
|
||||
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
|
||||
|
||||
|
||||
# Extract the channel name from the table name
|
||||
channel = table_name.split("_")[1]
|
||||
|
||||
|
||||
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
|
||||
channel = int(channel) if channel.isdigit() else channel
|
||||
|
||||
# Add the channel to globals.channel_list if not already present
|
||||
if channel not in globals.channel_list and not is_chat_archived(channel):
|
||||
globals.channel_list.append(channel)
|
||||
|
||||
# Add the channel to ui_state.channel_list if not already present
|
||||
if channel not in ui_state.channel_list and not is_chat_archived(channel):
|
||||
ui_state.channel_list.append(channel)
|
||||
# Ensure the channel exists in globals.all_messages
|
||||
if channel not in globals.all_messages:
|
||||
globals.all_messages[channel] = []
|
||||
|
||||
# Ensure the channel exists in ui_state.all_messages
|
||||
if channel not in ui_state.all_messages:
|
||||
ui_state.all_messages[channel] = []
|
||||
|
||||
# Add messages to ui_state.all_messages grouped by hourly timestamp
|
||||
# Add messages to globals.all_messages grouped by hourly timestamp
|
||||
hourly_messages = {}
|
||||
for row in db_messages:
|
||||
user_id, message, timestamp, ack_type = row
|
||||
|
||||
# Only ack_type is allowed to be None
|
||||
if user_id is None or message is None or timestamp is None:
|
||||
logging.warning(f"Skipping row with NULL required field(s): {row}")
|
||||
continue
|
||||
|
||||
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
|
||||
for user_id, message, timestamp, ack_type in db_messages:
|
||||
hour = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:00')
|
||||
if hour not in hourly_messages:
|
||||
hourly_messages[hour] = []
|
||||
|
||||
|
||||
ack_str = config.ack_unknown_str
|
||||
if ack_type == "Implicit":
|
||||
ack_str = config.ack_implicit_str
|
||||
@@ -136,27 +123,17 @@ def load_messages_from_db() -> None:
|
||||
elif ack_type == "Nak":
|
||||
ack_str = config.nak_str
|
||||
|
||||
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
|
||||
|
||||
if user_id == str(interface_state.myNodeNum):
|
||||
sanitized_message = message.replace("\x00", "")
|
||||
formatted_message = (
|
||||
f"{ts_str} {config.sent_message_prefix}{ack_str}: ",
|
||||
sanitized_message,
|
||||
)
|
||||
if user_id == str(globals.myNodeNum):
|
||||
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
|
||||
else:
|
||||
sanitized_message = message.replace("\x00", "")
|
||||
formatted_message = (
|
||||
f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
|
||||
sanitized_message,
|
||||
)
|
||||
|
||||
formatted_message = (f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ", message)
|
||||
|
||||
hourly_messages[hour].append(formatted_message)
|
||||
|
||||
# Flatten the hourly messages into ui_state.all_messages[channel]
|
||||
# Flatten the hourly messages into globals.all_messages[channel]
|
||||
for hour, messages in sorted(hourly_messages.items()):
|
||||
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
|
||||
ui_state.all_messages[channel].extend(messages)
|
||||
globals.all_messages[channel].append((f"-- {hour} --", ""))
|
||||
globals.all_messages[channel].extend(messages)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
|
||||
@@ -165,26 +142,26 @@ def load_messages_from_db() -> None:
|
||||
logging.error(f"SQLite error in load_messages_from_db: {e}")
|
||||
|
||||
|
||||
def init_nodedb() -> None:
|
||||
def init_nodedb():
|
||||
"""Initialize the node database and update it with nodes from the interface."""
|
||||
|
||||
|
||||
try:
|
||||
if not interface_state.interface.nodes:
|
||||
if not globals.interface.nodes:
|
||||
return # No nodes to initialize
|
||||
|
||||
ensure_node_table_exists() # Ensure the table exists before insertion
|
||||
nodes_snapshot = list(interface_state.interface.nodes.values())
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
|
||||
# Insert or update all nodes
|
||||
for node in nodes_snapshot:
|
||||
update_node_info_in_db(
|
||||
user_id=node["num"],
|
||||
long_name=node["user"].get("longName", ""),
|
||||
short_name=node["user"].get("shortName", ""),
|
||||
hw_model=node["user"].get("hwModel", ""),
|
||||
is_licensed=node["user"].get("isLicensed", "0"),
|
||||
role=node["user"].get("role", "CLIENT"),
|
||||
public_key=node["user"].get("publicKey", ""),
|
||||
user_id=node['num'],
|
||||
long_name=node['user'].get('longName', ''),
|
||||
short_name=node['user'].get('shortName', ''),
|
||||
hw_model=node['user'].get('hwModel', ''),
|
||||
is_licensed=node['user'].get('isLicensed', '0'),
|
||||
role=node['user'].get('role', 'CLIENT'),
|
||||
public_key=node['user'].get('publicKey', '')
|
||||
)
|
||||
|
||||
logging.info("Node database initialized successfully.")
|
||||
@@ -195,16 +172,16 @@ def init_nodedb() -> None:
|
||||
logging.error(f"Unexpected error in init_nodedb: {e}")
|
||||
|
||||
|
||||
def maybe_store_nodeinfo_in_db(packet: Dict[str, object]) -> None:
|
||||
def maybe_store_nodeinfo_in_db(packet):
|
||||
"""Save nodeinfo unless that record is already there, updating if necessary."""
|
||||
try:
|
||||
user_id = packet["from"]
|
||||
long_name = packet["decoded"]["user"]["longName"]
|
||||
short_name = packet["decoded"]["user"]["shortName"]
|
||||
hw_model = packet["decoded"]["user"]["hwModel"]
|
||||
is_licensed = packet["decoded"]["user"].get("isLicensed", "0")
|
||||
role = packet["decoded"]["user"].get("role", "CLIENT")
|
||||
public_key = packet["decoded"]["user"].get("publicKey", "")
|
||||
user_id = packet['from']
|
||||
long_name = packet['decoded']['user']['longName']
|
||||
short_name = packet['decoded']['user']['shortName']
|
||||
hw_model = packet['decoded']['user']['hwModel']
|
||||
is_licensed = packet['decoded']['user'].get('isLicensed', '0')
|
||||
role = packet['decoded']['user'].get('role', 'CLIENT')
|
||||
public_key = packet['decoded']['user'].get('publicKey', '')
|
||||
|
||||
update_node_info_in_db(user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
|
||||
|
||||
@@ -213,44 +190,26 @@ def maybe_store_nodeinfo_in_db(packet: Dict[str, object]) -> None:
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}")
|
||||
|
||||
|
||||
def update_node_info_in_db(
|
||||
user_id: Union[int, str],
|
||||
long_name: Optional[str] = None,
|
||||
short_name: Optional[str] = None,
|
||||
hw_model: Optional[str] = None,
|
||||
is_licensed: Optional[Union[str, int]] = None,
|
||||
role: Optional[str] = None,
|
||||
public_key: Optional[str] = None,
|
||||
chat_archived: Optional[int] = None,
|
||||
) -> None:
|
||||
def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=None, is_licensed=None, role=None, public_key=None, chat_archived=None):
|
||||
"""Update or insert node information into the database, preserving unchanged fields."""
|
||||
try:
|
||||
ensure_node_table_exists() # Ensure the table exists before any operation
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names
|
||||
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names
|
||||
|
||||
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({table_name})")]
|
||||
table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({table_name})')]
|
||||
if "chat_archived" not in table_columns:
|
||||
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
|
||||
db_cursor.execute(update_table_query)
|
||||
|
||||
# Fetch existing values to preserve unchanged fields
|
||||
db_cursor.execute(f"SELECT * FROM {table_name} WHERE user_id = ?", (user_id,))
|
||||
db_cursor.execute(f'SELECT * FROM {table_name} WHERE user_id = ?', (user_id,))
|
||||
existing_record = db_cursor.fetchone()
|
||||
|
||||
if existing_record:
|
||||
(
|
||||
existing_long_name,
|
||||
existing_short_name,
|
||||
existing_hw_model,
|
||||
existing_is_licensed,
|
||||
existing_role,
|
||||
existing_public_key,
|
||||
existing_chat_archived,
|
||||
) = existing_record[1:]
|
||||
existing_long_name, existing_short_name, existing_hw_model, existing_is_licensed, existing_role, existing_public_key, existing_chat_archived = existing_record[1:]
|
||||
|
||||
long_name = long_name if long_name is not None else existing_long_name
|
||||
short_name = short_name if short_name is not None else existing_short_name
|
||||
@@ -269,7 +228,7 @@ def update_node_info_in_db(
|
||||
chat_archived = chat_archived if chat_archived is not None else 0
|
||||
|
||||
# Upsert logic
|
||||
upsert_query = f"""
|
||||
upsert_query = f'''
|
||||
INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
@@ -280,10 +239,8 @@ def update_node_info_in_db(
|
||||
role = excluded.role,
|
||||
public_key = excluded.public_key,
|
||||
chat_archived = excluded.chat_archived
|
||||
"""
|
||||
db_cursor.execute(
|
||||
upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
|
||||
)
|
||||
'''
|
||||
db_cursor.execute(upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived))
|
||||
db_connection.commit()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
@@ -292,10 +249,10 @@ def update_node_info_in_db(
|
||||
logging.error(f"Unexpected error in update_node_info_in_db: {e}")
|
||||
|
||||
|
||||
def ensure_node_table_exists() -> None:
|
||||
def ensure_node_table_exists():
|
||||
"""Ensure the node database table exists."""
|
||||
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote for safety
|
||||
schema = """
|
||||
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote for safety
|
||||
schema = '''
|
||||
user_id TEXT PRIMARY KEY,
|
||||
long_name TEXT,
|
||||
short_name TEXT,
|
||||
@@ -304,11 +261,11 @@ def ensure_node_table_exists() -> None:
|
||||
role TEXT,
|
||||
public_key TEXT,
|
||||
chat_archived INTEGER
|
||||
"""
|
||||
'''
|
||||
ensure_table_exists(table_name, schema)
|
||||
|
||||
|
||||
def ensure_table_exists(table_name: str, schema: str) -> None:
|
||||
def ensure_table_exists(table_name, schema):
|
||||
"""Ensure the given table exists in the database."""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
@@ -322,10 +279,10 @@ def ensure_table_exists(table_name: str, schema: str) -> None:
|
||||
logging.error(f"Unexpected error in ensure_table_exists({table_name}): {e}")
|
||||
|
||||
|
||||
def get_name_from_database(user_id: int, type: str = "long") -> str:
|
||||
def get_name_from_database(user_id, type="long"):
|
||||
"""
|
||||
Retrieve a user's name (long or short) from the node database.
|
||||
|
||||
|
||||
:param user_id: The user ID to look up.
|
||||
:param type: "long" for long name, "short" for short name.
|
||||
:return: The retrieved name or the hex of the user id
|
||||
@@ -335,9 +292,9 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
|
||||
db_cursor = db_connection.cursor()
|
||||
|
||||
# Construct table name
|
||||
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote table name for safety
|
||||
|
||||
|
||||
# Determine the correct column to fetch
|
||||
column_name = "long_name" if type == "long" else "short_name"
|
||||
|
||||
@@ -356,12 +313,11 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
|
||||
logging.error(f"Unexpected error in get_name_from_database: {e}")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def is_chat_archived(user_id: int) -> int:
|
||||
def is_chat_archived(user_id):
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"'
|
||||
query = f"SELECT chat_archived FROM {nodeinfo_table} WHERE user_id = ?"
|
||||
db_cursor.execute(query, (user_id,))
|
||||
@@ -376,3 +332,4 @@ def is_chat_archived(user_id: int) -> int:
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in is_chat_archived: {e}")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
@@ -2,72 +2,56 @@ import base64
|
||||
import binascii
|
||||
import curses
|
||||
import ipaddress
|
||||
from typing import Any, Optional, List
|
||||
|
||||
import re
|
||||
from contact.ui.colors import get_color
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.validation_rules import get_validation_for
|
||||
from contact.utilities.singleton import menu_state
|
||||
|
||||
# Dialogs should be at most 80 cols, but shrink on small terminals
|
||||
MAX_DIALOG_WIDTH = 80
|
||||
MIN_DIALOG_WIDTH = 20
|
||||
def wrap_text(text, wrap_width):
|
||||
"""Wraps text while preserving spaces and breaking long words."""
|
||||
words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately
|
||||
wrapped_lines = []
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
margin = 2 # Left and right margin
|
||||
wrap_width -= margin
|
||||
|
||||
for word in words:
|
||||
word_length = len(word)
|
||||
|
||||
def get_dialog_width() -> int:
|
||||
# Leave 2 columns for borders and clamp to a sane minimum
|
||||
try:
|
||||
return max(MIN_DIALOG_WIDTH, min(MAX_DIALOG_WIDTH, curses.COLS - 2))
|
||||
except Exception:
|
||||
# Fallback if curses not ready yet
|
||||
return MAX_DIALOG_WIDTH
|
||||
if word_length > wrap_width: # Break long words
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer)
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
for i in range(0, word_length, wrap_width):
|
||||
wrapped_lines.append(word[i:i+wrap_width])
|
||||
continue
|
||||
|
||||
if line_length + word_length > wrap_width and word.strip():
|
||||
wrapped_lines.append(line_buffer)
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
|
||||
def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None:
|
||||
"""Displays an invalid input message in the given window and redraws if needed."""
|
||||
cursor_y, cursor_x = window.getyx()
|
||||
curses.curs_set(0)
|
||||
dialog("Invalid Input", message)
|
||||
if redraw_func:
|
||||
redraw_func() # Redraw the original window content that got obscured
|
||||
else:
|
||||
window.refresh()
|
||||
window.move(cursor_y, cursor_x)
|
||||
curses.curs_set(1)
|
||||
line_buffer += word
|
||||
line_length += word_length
|
||||
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer)
|
||||
|
||||
def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]:
|
||||
return wrapped_lines
|
||||
|
||||
|
||||
def get_text_input(prompt):
|
||||
"""Handles user input with wrapped text for long prompts."""
|
||||
|
||||
def redraw_input_win():
|
||||
"""Redraw the input window with the current prompt and user input."""
|
||||
input_win.erase()
|
||||
input_win.border()
|
||||
row = 1
|
||||
for line in wrapped_prompt:
|
||||
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
||||
row += 1
|
||||
if row >= height - 3:
|
||||
break
|
||||
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||
input_win.addstr(row + 1, col_start, user_input[:first_line_width], get_color("settings_default"))
|
||||
for i, line in enumerate(wrap_text(user_input[first_line_width:], wrap_width=input_width)):
|
||||
if row + 2 + i < height - 1:
|
||||
input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default"))
|
||||
input_win.refresh()
|
||||
|
||||
height = 8
|
||||
width = get_dialog_width()
|
||||
width = 80
|
||||
margin = 2 # Left and right margin
|
||||
input_width = width - (2 * margin) # Space available for text
|
||||
max_input_rows = height - 4 # Space for input
|
||||
|
||||
start_y = max(0, (curses.LINES - height) // 2)
|
||||
start_x = max(0, (curses.COLS - width) // 2)
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
input_win = curses.newwin(height, width, start_y, start_x)
|
||||
input_win.timeout(200)
|
||||
input_win.bkgd(get_color("background"))
|
||||
input_win.attrset(get_color("window_frame"))
|
||||
input_win.border()
|
||||
@@ -75,7 +59,6 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
|
||||
# Wrap the prompt text
|
||||
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
|
||||
row = 1
|
||||
|
||||
for line in wrapped_prompt:
|
||||
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
||||
row += 1
|
||||
@@ -84,140 +67,49 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
|
||||
|
||||
prompt_text = "Enter new value: "
|
||||
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||
|
||||
|
||||
input_win.refresh()
|
||||
curses.curs_set(1)
|
||||
|
||||
min_value = 0
|
||||
max_value = 4294967295
|
||||
min_length = 0
|
||||
max_length = None
|
||||
|
||||
if selected_config is not None:
|
||||
validation = get_validation_for(selected_config) or {}
|
||||
min_value = validation.get("min_value", 0)
|
||||
max_value = validation.get("max_value", 4294967295)
|
||||
min_length = validation.get("min_length", 0)
|
||||
max_length = validation.get("max_length")
|
||||
|
||||
max_length = 4 if "shortName" in prompt else None
|
||||
user_input = ""
|
||||
|
||||
# Start user input after the prompt text
|
||||
col_start = margin + len(prompt_text)
|
||||
first_line_width = input_width - len(prompt_text)
|
||||
first_line_width = input_width - len(prompt_text) # Available space for first line
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
redraw_input_win()
|
||||
key = input_win.get_wch() # Waits for user input
|
||||
|
||||
try:
|
||||
key = input_win.get_wch()
|
||||
except curses.error:
|
||||
continue
|
||||
|
||||
if key == chr(27) or key == curses.KEY_LEFT:
|
||||
if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
input_win.erase()
|
||||
input_win.refresh()
|
||||
curses.curs_set(0)
|
||||
menu_state.need_redraw = True
|
||||
return None
|
||||
return None # Exit without saving
|
||||
|
||||
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
menu_state.need_redraw = True
|
||||
|
||||
if not user_input.strip():
|
||||
invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win)
|
||||
continue
|
||||
|
||||
length = len(user_input)
|
||||
if min_length == max_length and max_length is not None:
|
||||
if length != min_length:
|
||||
invalid_input(
|
||||
input_win, f"Value must be exactly {min_length} characters long.", redraw_func=redraw_input_win
|
||||
)
|
||||
continue
|
||||
else:
|
||||
if length < min_length:
|
||||
invalid_input(
|
||||
input_win,
|
||||
f"Value must be at least {min_length} characters long.",
|
||||
redraw_func=redraw_input_win,
|
||||
)
|
||||
continue
|
||||
if max_length is not None and length > max_length:
|
||||
invalid_input(
|
||||
input_win,
|
||||
f"Value must be no more than {max_length} characters long.",
|
||||
redraw_func=redraw_input_win,
|
||||
)
|
||||
continue
|
||||
|
||||
if input_type is int:
|
||||
if not user_input.isdigit():
|
||||
invalid_input(input_win, "Only numeric digits (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
|
||||
)
|
||||
continue
|
||||
|
||||
curses.curs_set(0)
|
||||
return int_val
|
||||
|
||||
elif input_type is float:
|
||||
try:
|
||||
float_val = float(user_input)
|
||||
if not (min_value <= float_val <= max_value):
|
||||
invalid_input(
|
||||
input_win,
|
||||
f"Enter a number between {min_value} and {max_value}.",
|
||||
redraw_func=redraw_input_win,
|
||||
)
|
||||
continue
|
||||
except ValueError:
|
||||
invalid_input(input_win, "Must be a valid floating point number.", redraw_func=redraw_input_win)
|
||||
continue
|
||||
else:
|
||||
curses.curs_set(0)
|
||||
return float_val
|
||||
|
||||
else:
|
||||
break
|
||||
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): # Enter key
|
||||
break
|
||||
|
||||
elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace
|
||||
if user_input:
|
||||
user_input = user_input[:-1] # Remove last character
|
||||
|
||||
elif max_length is None or len(user_input) < max_length:
|
||||
try:
|
||||
char = chr(key) if not isinstance(key, str) else key
|
||||
if input_type is int:
|
||||
if char.isdigit() or (char == "-" and len(user_input) == 0):
|
||||
user_input += char
|
||||
elif input_type is float:
|
||||
if (
|
||||
char.isdigit()
|
||||
or (char == "." and "." not in user_input)
|
||||
or (char == "-" and len(user_input) == 0)
|
||||
):
|
||||
user_input += char
|
||||
else:
|
||||
user_input += char
|
||||
except ValueError:
|
||||
pass # Ignore invalid input
|
||||
elif max_length is None or len(user_input) < max_length: # Enforce max length
|
||||
if isinstance(key, str):
|
||||
user_input += key
|
||||
else:
|
||||
user_input += chr(key)
|
||||
|
||||
# First line must be manually handled before using wrap_text()
|
||||
first_line = user_input[:first_line_width] # Cut to max first line width
|
||||
remaining_text = user_input[first_line_width:] # Remaining text for wrapping
|
||||
|
||||
wrapped_lines = wrap_text(remaining_text, wrap_width=input_width) if remaining_text else []
|
||||
|
||||
|
||||
# Clear only the input area (without touching prompt text)
|
||||
for i in range(max_input_rows):
|
||||
if row + 1 + i < height - 1:
|
||||
input_win.addstr(row + 1 + i, margin, " " * input_width, get_color("settings_default"))
|
||||
input_win.addstr(row + 1 + i, margin, " " * min(input_width, width - margin - 1), get_color("settings_default"))
|
||||
|
||||
# Redraw the prompt text so it never disappears
|
||||
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||
@@ -233,37 +125,32 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
|
||||
curses.curs_set(0)
|
||||
input_win.erase()
|
||||
input_win.refresh()
|
||||
return user_input.strip()
|
||||
return user_input
|
||||
|
||||
|
||||
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||
"""Handles user input for editing up to 3 Admin Keys in Base64 format."""
|
||||
|
||||
def get_admin_key_input(current_value):
|
||||
def to_base64(byte_strings):
|
||||
"""Convert byte values to Base64-encoded strings."""
|
||||
return [base64.b64encode(b).decode() for b in byte_strings]
|
||||
|
||||
def is_valid_base64(s):
|
||||
"""Check if a string is valid Base64 or blank."""
|
||||
if s == "":
|
||||
return True
|
||||
"""Check if a string is valid Base64."""
|
||||
try:
|
||||
decoded = base64.b64decode(s, validate=True)
|
||||
return len(decoded) == 32 # Ensure it's exactly 32 bytes
|
||||
except (binascii.Error, ValueError):
|
||||
except binascii.Error:
|
||||
return False
|
||||
|
||||
cvalue = to_base64(current_value) # Convert current values to Base64
|
||||
height = 9
|
||||
width = get_dialog_width()
|
||||
start_y = max(0, (curses.LINES - height) // 2)
|
||||
start_x = max(0, (curses.COLS - width) // 2)
|
||||
width = 80
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
admin_key_win = curses.newwin(height, width, start_y, start_x)
|
||||
admin_key_win.timeout(200)
|
||||
admin_key_win.bkgd(get_color("background"))
|
||||
admin_key_win.attrset(get_color("window_frame"))
|
||||
admin_key_win.keypad(True) # Enable keypad for special keys
|
||||
repeated_win = curses.newwin(height, width, start_y, start_x)
|
||||
repeated_win.bkgd(get_color("background"))
|
||||
repeated_win.attrset(get_color("window_frame"))
|
||||
repeated_win.keypad(True) # Enable keypad for special keys
|
||||
|
||||
curses.echo()
|
||||
curses.curs_set(1)
|
||||
@@ -271,48 +158,44 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||
# Editable list of values (max 3 values)
|
||||
user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields
|
||||
cursor_pos = 0 # Track which value is being edited
|
||||
invalid_input = ""
|
||||
error_message = ""
|
||||
|
||||
while True:
|
||||
admin_key_win.erase()
|
||||
admin_key_win.border()
|
||||
admin_key_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
|
||||
repeated_win.erase()
|
||||
repeated_win.border()
|
||||
repeated_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
|
||||
|
||||
# Display current values, allowing editing
|
||||
for i, line in enumerate(user_values):
|
||||
prefix = "→ " if i == cursor_pos else " " # Highlight the current line
|
||||
admin_key_win.addstr(
|
||||
3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
||||
)
|
||||
admin_key_win.addstr(3 + i, 18, line) # Align text for easier editing
|
||||
repeated_win.addstr(3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
|
||||
repeated_win.addstr(3 + i, 18, line) # Align text for easier editing
|
||||
|
||||
# Move cursor to the correct position inside the field
|
||||
curses.curs_set(1)
|
||||
admin_key_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||
|
||||
# Show error message if needed
|
||||
if invalid_input:
|
||||
admin_key_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True))
|
||||
if error_message:
|
||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
||||
|
||||
admin_key_win.refresh()
|
||||
key = admin_key_win.getch()
|
||||
repeated_win.refresh()
|
||||
key = repeated_win.getch()
|
||||
|
||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
|
||||
admin_key_win.erase()
|
||||
admin_key_win.refresh()
|
||||
repeated_win.erase()
|
||||
repeated_win.refresh()
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
menu_state.need_redraw = True
|
||||
return None
|
||||
|
||||
elif key == ord("\n"): # Enter key to save and return
|
||||
menu_state.need_redraw = True
|
||||
|
||||
elif key == ord('\n'): # Enter key to save and return
|
||||
if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return user_values # Return the edited Base64 values
|
||||
else:
|
||||
invalid_input = "Error: Each key must be valid Base64 and 32 bytes long!"
|
||||
error_message = "Error: Each key must be valid Base64 and 32 bytes long!"
|
||||
elif key == curses.KEY_UP: # Move cursor up
|
||||
cursor_pos = (cursor_pos - 1) % len(user_values)
|
||||
elif key == curses.KEY_DOWN: # Move cursor down
|
||||
@@ -323,252 +206,249 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||
else:
|
||||
try:
|
||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
||||
invalid_input = "" # Clear error if user starts fixing input
|
||||
error_message = "" # Clear error if user starts fixing input
|
||||
except ValueError:
|
||||
pass # Ignore invalid character inputs
|
||||
|
||||
|
||||
from contact.utilities.singleton import menu_state # Required if not already imported
|
||||
|
||||
|
||||
def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
||||
def get_repeated_input(current_value):
|
||||
height = 9
|
||||
width = get_dialog_width()
|
||||
start_y = max(0, (curses.LINES - height) // 2)
|
||||
start_x = max(0, (curses.COLS - width) // 2)
|
||||
width = 80
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
repeated_win = curses.newwin(height, width, start_y, start_x)
|
||||
repeated_win.timeout(200)
|
||||
repeated_win.bkgd(get_color("background"))
|
||||
repeated_win.attrset(get_color("window_frame"))
|
||||
repeated_win.keypad(True)
|
||||
repeated_win.keypad(True) # Enable keypad for special keys
|
||||
|
||||
curses.echo()
|
||||
curses.curs_set(1)
|
||||
curses.curs_set(1) # Show the cursor
|
||||
|
||||
user_values = current_value[:3] + [""] * (3 - len(current_value)) # Always 3 fields
|
||||
cursor_pos = 0
|
||||
invalid_input = ""
|
||||
# Editable list of values (max 3 values)
|
||||
user_values = current_value[:3]
|
||||
cursor_pos = 0 # Track which value is being edited
|
||||
error_message = ""
|
||||
|
||||
def redraw():
|
||||
while True:
|
||||
repeated_win.erase()
|
||||
repeated_win.border()
|
||||
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
|
||||
|
||||
win_h, win_w = repeated_win.getmaxyx()
|
||||
# Display current values, allowing editing
|
||||
for i, line in enumerate(user_values):
|
||||
prefix = "→ " if i == cursor_pos else " "
|
||||
repeated_win.addstr(
|
||||
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
||||
)
|
||||
repeated_win.addstr(3 + i, 18, line[: max(0, win_w - 20)]) # Prevent overflow
|
||||
prefix = "→ " if i == cursor_pos else " " # Highlight the current line
|
||||
repeated_win.addstr(3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
|
||||
repeated_win.addstr(3 + i, 18, line)
|
||||
|
||||
if invalid_input:
|
||||
win_h, win_w = repeated_win.getmaxyx()
|
||||
repeated_win.addstr(7, 2, invalid_input[: max(0, win_w - 4)], get_color("settings_default", bold=True))
|
||||
# Move cursor to the correct position inside the field
|
||||
curses.curs_set(1)
|
||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||
|
||||
# Show error message if needed
|
||||
if error_message:
|
||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
||||
|
||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos]))
|
||||
repeated_win.refresh()
|
||||
key = repeated_win.getch()
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
redraw()
|
||||
|
||||
redraw()
|
||||
|
||||
try:
|
||||
key = repeated_win.get_wch()
|
||||
except curses.error:
|
||||
continue # ignore timeout or input issues
|
||||
|
||||
if key in (27, curses.KEY_LEFT): # ESC or Left Arrow
|
||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
|
||||
repeated_win.erase()
|
||||
repeated_win.refresh()
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
menu_state.need_redraw = True
|
||||
return None
|
||||
elif key in ("\n", curses.KEY_ENTER):
|
||||
|
||||
elif key == ord('\n'): # Enter key to save and return
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
menu_state.need_redraw = True
|
||||
return ", ".join(user_values).strip()
|
||||
elif key == curses.KEY_UP:
|
||||
cursor_pos = (cursor_pos - 1) % 3
|
||||
elif key == curses.KEY_DOWN:
|
||||
cursor_pos = (cursor_pos + 1) % 3
|
||||
elif key in (curses.KEY_BACKSPACE, 127):
|
||||
user_values[cursor_pos] = user_values[cursor_pos][:-1]
|
||||
return ", ".join(user_values)
|
||||
elif key == curses.KEY_UP: # Move cursor up
|
||||
cursor_pos = (cursor_pos - 1) % len(user_values)
|
||||
elif key == curses.KEY_DOWN: # Move cursor down
|
||||
cursor_pos = (cursor_pos + 1) % len(user_values)
|
||||
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
|
||||
if len(user_values[cursor_pos]) > 0:
|
||||
user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character
|
||||
else:
|
||||
try:
|
||||
ch = chr(key) if isinstance(key, int) else key
|
||||
if ch.isprintable():
|
||||
user_values[cursor_pos] += ch
|
||||
invalid_input = ""
|
||||
except Exception:
|
||||
pass
|
||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
||||
error_message = "" # Clear error if user starts fixing input
|
||||
except ValueError:
|
||||
pass # Ignore invalid character inputs
|
||||
|
||||
|
||||
from contact.utilities.singleton import menu_state # Ensure this is imported
|
||||
|
||||
|
||||
def get_fixed32_input(current_value: int) -> int:
|
||||
original_value = current_value
|
||||
ip_string = str(ipaddress.IPv4Address(current_value))
|
||||
def get_fixed32_input(current_value):
|
||||
cvalue = current_value
|
||||
current_value = str(ipaddress.IPv4Address(current_value))
|
||||
height = 10
|
||||
width = get_dialog_width()
|
||||
start_y = max(0, (curses.LINES - height) // 2)
|
||||
start_x = max(0, (curses.COLS - width) // 2)
|
||||
width = 80
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
fixed32_win = curses.newwin(height, width, start_y, start_x)
|
||||
fixed32_win.bkgd(get_color("background"))
|
||||
fixed32_win.attrset(get_color("window_frame"))
|
||||
fixed32_win.keypad(True)
|
||||
fixed32_win.timeout(200)
|
||||
|
||||
curses.echo()
|
||||
curses.curs_set(1)
|
||||
user_input = ""
|
||||
|
||||
def redraw():
|
||||
while True:
|
||||
fixed32_win.erase()
|
||||
fixed32_win.border()
|
||||
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", get_color("settings_default", bold=True))
|
||||
fixed32_win.addstr(3, 2, f"Current: {ip_string}", get_color("settings_default"))
|
||||
fixed32_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default"))
|
||||
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
|
||||
fixed32_win.addstr(3, 2, f"Current: {current_value}")
|
||||
fixed32_win.addstr(5, 2, f"New value: {user_input}")
|
||||
fixed32_win.refresh()
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
redraw()
|
||||
key = fixed32_win.getch()
|
||||
|
||||
redraw()
|
||||
|
||||
try:
|
||||
key = fixed32_win.get_wch()
|
||||
except curses.error:
|
||||
continue # ignore timeout
|
||||
|
||||
if key in (27, curses.KEY_LEFT): # ESC or Left Arrow to cancel
|
||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow to cancel
|
||||
fixed32_win.erase()
|
||||
fixed32_win.refresh()
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
menu_state.need_redraw = True
|
||||
return original_value
|
||||
|
||||
elif key in ("\n", curses.KEY_ENTER):
|
||||
return cvalue # Return the current value unchanged
|
||||
elif key == ord('\n'): # Enter key to validate and save
|
||||
# Validate IP address
|
||||
octets = user_input.split(".")
|
||||
menu_state.need_redraw = True
|
||||
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return int(ipaddress.ip_address(user_input))
|
||||
fixed32_address = ipaddress.ip_address(user_input)
|
||||
return int(fixed32_address) # Return the valid IP address
|
||||
else:
|
||||
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", get_color("settings_default", bold=True))
|
||||
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
|
||||
fixed32_win.refresh()
|
||||
curses.napms(1500)
|
||||
user_input = ""
|
||||
|
||||
elif key in (curses.KEY_BACKSPACE, 127):
|
||||
curses.napms(1500) # Wait for 1.5 seconds before refreshing
|
||||
user_input = "" # Clear invalid input
|
||||
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
|
||||
user_input = user_input[:-1]
|
||||
|
||||
else:
|
||||
try:
|
||||
ch = chr(key) if isinstance(key, int) else key
|
||||
if ch.isdigit() or ch == ".":
|
||||
user_input += ch
|
||||
except Exception:
|
||||
pass # Ignore unprintable inputs
|
||||
char = chr(key)
|
||||
if char.isdigit() or char == ".":
|
||||
user_input += char # Append only valid characters (digits or dots)
|
||||
except ValueError:
|
||||
pass # Ignore invalid inputs
|
||||
|
||||
|
||||
from typing import List, Optional # ensure Optional is imported
|
||||
|
||||
|
||||
def get_list_input(
|
||||
prompt: str, current_option: Optional[str], list_options: List[str], mandatory: bool = False
|
||||
) -> Optional[str]:
|
||||
def get_list_input(prompt, current_option, list_options):
|
||||
"""
|
||||
List selector.
|
||||
Displays a scrollable list of list_options for the user to choose from.
|
||||
"""
|
||||
selected_index = list_options.index(current_option) if current_option in list_options else 0
|
||||
|
||||
height = min(len(list_options) + 5, curses.LINES)
|
||||
width = get_dialog_width()
|
||||
start_y = max(0, (curses.LINES - height) // 2)
|
||||
start_x = max(0, (curses.COLS - width) // 2)
|
||||
width = 80
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
list_win = curses.newwin(height, width, start_y, start_x)
|
||||
list_win.timeout(200)
|
||||
list_win.bkgd(get_color("background"))
|
||||
list_win.attrset(get_color("window_frame"))
|
||||
list_win.keypad(True)
|
||||
|
||||
list_pad = curses.newpad(len(list_options) + 1, max(1, width - 8))
|
||||
list_pad = curses.newpad(len(list_options) + 1, width - 8)
|
||||
list_pad.bkgd(get_color("background"))
|
||||
|
||||
# Render header
|
||||
list_win.erase()
|
||||
list_win.border()
|
||||
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
|
||||
|
||||
# Render options on the pad
|
||||
for idx, color in enumerate(list_options):
|
||||
if idx == selected_index:
|
||||
list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True))
|
||||
else:
|
||||
list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
|
||||
|
||||
# Initial refresh
|
||||
list_win.refresh()
|
||||
list_pad.refresh(0, 0,
|
||||
list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4,
|
||||
list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4)
|
||||
|
||||
max_index = len(list_options) - 1
|
||||
visible_height = list_win.getmaxyx()[0] - 5
|
||||
|
||||
def redraw_list_ui():
|
||||
list_win.erase()
|
||||
list_win.border()
|
||||
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
|
||||
|
||||
win_h, win_w = list_win.getmaxyx()
|
||||
pad_w = max(1, win_w - 8)
|
||||
for idx, item in enumerate(list_options):
|
||||
color = get_color("settings_default", reverse=(idx == selected_index))
|
||||
list_pad.addstr(idx, 0, item[:pad_w].ljust(pad_w), color)
|
||||
|
||||
list_win.refresh()
|
||||
list_pad.refresh(
|
||||
0,
|
||||
0,
|
||||
list_win.getbegyx()[0] + 3,
|
||||
list_win.getbegyx()[1] + 4,
|
||||
list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2,
|
||||
list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4,
|
||||
)
|
||||
# Recompute visible height each draw in case of resize
|
||||
vis_h = list_win.getmaxyx()[0] - 5
|
||||
draw_arrows(list_win, vis_h, max_index, [0], show_save_option=False)
|
||||
|
||||
# Initial draw
|
||||
redraw_list_ui()
|
||||
draw_arrows(list_win, visible_height, max_index, 0)
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
menu_state.need_redraw = False
|
||||
redraw_list_ui()
|
||||
|
||||
try:
|
||||
key = list_win.getch()
|
||||
except curses.error:
|
||||
continue
|
||||
key = list_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
old_selected_index = selected_index
|
||||
selected_index = max(0, selected_index - 1)
|
||||
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
|
||||
|
||||
move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad)
|
||||
elif key == curses.KEY_DOWN:
|
||||
old_selected_index = selected_index
|
||||
selected_index = min(len(list_options) - 1, selected_index + 1)
|
||||
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
|
||||
|
||||
elif key == ord("\n"): # Enter
|
||||
move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad)
|
||||
elif key == ord('\n'): # Enter key
|
||||
list_win.clear()
|
||||
list_win.refresh()
|
||||
menu_state.need_redraw = True
|
||||
return list_options[selected_index]
|
||||
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left
|
||||
if mandatory:
|
||||
continue
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
list_win.clear()
|
||||
list_win.refresh()
|
||||
menu_state.need_redraw = True
|
||||
return current_option
|
||||
|
||||
|
||||
def move_highlight(old_idx, new_idx, options, list_win, list_pad):
|
||||
|
||||
global scroll_offset
|
||||
if 'scroll_offset' not in globals():
|
||||
scroll_offset = 0 # Initialize if not set
|
||||
|
||||
if old_idx == new_idx:
|
||||
return # No-op
|
||||
|
||||
max_index = len(options) - 1
|
||||
visible_height = list_win.getmaxyx()[0] - 5
|
||||
|
||||
# Adjust scroll_offset only when moving out of visible range
|
||||
if new_idx < scroll_offset: # Moving above the visible area
|
||||
scroll_offset = new_idx
|
||||
elif new_idx >= scroll_offset + visible_height: # Moving below the visible area
|
||||
scroll_offset = new_idx - visible_height
|
||||
|
||||
# Ensure scroll_offset is within bounds
|
||||
scroll_offset = max(0, min(scroll_offset, max_index - visible_height + 1))
|
||||
|
||||
# Clear old highlight
|
||||
list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default"))
|
||||
|
||||
# Highlight new selection
|
||||
list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse=True))
|
||||
|
||||
list_win.refresh()
|
||||
|
||||
# Refresh pad only if scrolling is needed
|
||||
list_pad.refresh(scroll_offset, 0,
|
||||
list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4,
|
||||
list_win.getbegyx()[0] + 3 + visible_height,
|
||||
list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4)
|
||||
|
||||
draw_arrows(list_win, visible_height, max_index, scroll_offset)
|
||||
|
||||
return scroll_offset # Return updated scroll_offset to be stored externally
|
||||
|
||||
|
||||
def draw_arrows(win, visible_height, max_index, start_index):
|
||||
|
||||
if visible_height < max_index:
|
||||
if start_index > 0:
|
||||
win.addstr(3, 2, "▲", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(3, 2, " ", get_color("settings_default"))
|
||||
|
||||
if max_index - start_index > visible_height:
|
||||
win.addstr(visible_height + 3, 2, "▼", get_color("settings_default"))
|
||||
else:
|
||||
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
import logging
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
|
||||
import contact.globals as globals
|
||||
|
||||
def initialize_interface(args):
|
||||
try:
|
||||
|
||||
if args.ble:
|
||||
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
|
||||
|
||||
elif args.host:
|
||||
try:
|
||||
if ":" in args.host:
|
||||
tcp_hostname, tcp_port = args.host.split(":")
|
||||
else:
|
||||
tcp_hostname = args.host
|
||||
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
|
||||
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to {args.host}. {ex}")
|
||||
return meshtastic.tcp_interface.TCPInterface(args.host)
|
||||
else:
|
||||
try:
|
||||
client = meshtastic.serial_interface.SerialInterface(args.port)
|
||||
except FileNotFoundError as ex:
|
||||
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
|
||||
except PermissionError as ex:
|
||||
logging.error(
|
||||
f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}"
|
||||
)
|
||||
logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}")
|
||||
except Exception as ex:
|
||||
logging.error(f"Unexpected error initializing interface: {ex}")
|
||||
except OSError as ex:
|
||||
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
|
||||
if client.devPath is None:
|
||||
try:
|
||||
client = meshtastic.tcp_interface.TCPInterface("localhost")
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to localhost:{ex}")
|
||||
|
||||
client = meshtastic.tcp_interface.TCPInterface("localhost")
|
||||
|
||||
return client
|
||||
|
||||
except Exception as ex:
|
||||
logging.critical(f"Fatal error initializing interface: {ex}")
|
||||
|
||||
@@ -4,8 +4,7 @@ import logging
|
||||
import base64
|
||||
import time
|
||||
|
||||
|
||||
def save_changes(interface, modified_settings, menu_state):
|
||||
def save_changes(interface, modified_settings, state):
|
||||
"""
|
||||
Save changes to the device based on modified settings.
|
||||
:param interface: Meshtastic interface instance
|
||||
@@ -16,16 +15,16 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
if not modified_settings:
|
||||
logging.info("No changes to save. modified_settings is empty.")
|
||||
return
|
||||
|
||||
node = interface.getNode("^local")
|
||||
|
||||
node = interface.getNode('^local')
|
||||
admin_key_backup = None
|
||||
if "admin_key" in modified_settings:
|
||||
if 'admin_key' in modified_settings:
|
||||
# Get reference to security config
|
||||
security_config = node.localConfig.security
|
||||
admin_keys = modified_settings["admin_key"]
|
||||
admin_keys = modified_settings['admin_key']
|
||||
|
||||
# Filter out empty keys
|
||||
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
|
||||
valid_keys = [key for key in admin_keys if key and key.strip() and key != b'']
|
||||
|
||||
if not valid_keys:
|
||||
logging.warning("No valid admin keys provided. Skipping admin key update.")
|
||||
@@ -43,29 +42,29 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
security_config.admin_key.append(key)
|
||||
node.writeConfig("security")
|
||||
logging.info("Admin keys updated successfully!")
|
||||
|
||||
|
||||
# Backup 'admin_key' before removing it
|
||||
admin_key_backup = modified_settings.get("admin_key", None)
|
||||
admin_key_backup = modified_settings.get('admin_key', None)
|
||||
# Remove 'admin_key' from modified_settings to prevent interference
|
||||
del modified_settings["admin_key"]
|
||||
del modified_settings['admin_key']
|
||||
|
||||
# Return early if there are no other settings left to process
|
||||
if not modified_settings:
|
||||
return
|
||||
|
||||
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
|
||||
config_category = menu_state.menu_path[2].lower() # for radio and module configs
|
||||
if state.menu_path[1] == "Radio Settings" or state.menu_path[1] == "Module Settings":
|
||||
config_category = state.menu_path[2].lower() # for radio and module configs
|
||||
|
||||
if {"latitude", "longitude", "altitude"} & modified_settings.keys():
|
||||
lat = float(modified_settings.get("latitude", 0.0))
|
||||
lon = float(modified_settings.get("longitude", 0.0))
|
||||
alt = int(modified_settings.get("altitude", 0))
|
||||
if {'latitude', 'longitude', 'altitude'} & modified_settings.keys():
|
||||
lat = float(modified_settings.get('latitude', 0.0))
|
||||
lon = float(modified_settings.get('longitude', 0.0))
|
||||
alt = int(modified_settings.get('altitude', 0))
|
||||
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
||||
return
|
||||
|
||||
elif menu_state.menu_path[1] == "User Settings": # for user configs
|
||||
elif state.menu_path[1] == "User Settings": # for user configs
|
||||
config_category = "User Settings"
|
||||
long_name = modified_settings.get("longName")
|
||||
short_name = modified_settings.get("shortName")
|
||||
@@ -74,26 +73,24 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed)
|
||||
|
||||
logging.info(
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
|
||||
)
|
||||
logging.info(f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}")
|
||||
|
||||
return
|
||||
|
||||
elif menu_state.menu_path[1] == "Channels": # for channel configs
|
||||
|
||||
elif state.menu_path[1] == "Channels": # for channel configs
|
||||
config_category = "Channels"
|
||||
|
||||
try:
|
||||
channel = menu_state.menu_path[-1]
|
||||
channel = state.menu_path[-1]
|
||||
channel_num = int(channel.split()[-1]) - 1
|
||||
except (IndexError, ValueError) as e:
|
||||
channel_num = None
|
||||
|
||||
channel = node.channels[channel_num]
|
||||
for key, value in modified_settings.items():
|
||||
if key == "psk": # Special case: decode Base64 for psk
|
||||
if key == 'psk': # Special case: decode Base64 for psk
|
||||
channel.settings.psk = base64.b64decode(value)
|
||||
elif key == "position_precision": # Special case: module_settings
|
||||
elif key == 'position_precision': # Special case: module_settings
|
||||
channel.settings.module_settings.position_precision = value
|
||||
else:
|
||||
setattr(channel.settings, key, value) # Use setattr for other fields
|
||||
@@ -138,9 +135,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
setattr(field, sub_field, sub_value)
|
||||
logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}")
|
||||
else:
|
||||
logging.warning(
|
||||
f"Sub-field '{sub_field}' not found in {config_category}.{config_item}"
|
||||
)
|
||||
logging.warning(f"Sub-field '{sub_field}' not found in {config_category}.{config_item}")
|
||||
else:
|
||||
logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.")
|
||||
else:
|
||||
@@ -156,9 +151,9 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
logging.info(f"Changes written to config category: {config_category}")
|
||||
|
||||
if admin_key_backup is not None:
|
||||
modified_settings["admin_key"] = admin_key_backup
|
||||
modified_settings['admin_key'] = admin_key_backup
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving changes: {e}")
|
||||
logging.error(f"Error saving changes: {e}")
|
||||
@@ -1,6 +0,0 @@
|
||||
from contact.ui.ui_state import ChatUIState, InterfaceState, AppState, MenuState
|
||||
|
||||
ui_state = ChatUIState()
|
||||
interface_state = InterfaceState()
|
||||
app_state = AppState()
|
||||
menu_state = MenuState()
|
||||
@@ -1,90 +0,0 @@
|
||||
import datetime
|
||||
|
||||
sensors = {
|
||||
'temperature': {'icon':'🌡️ ','unit':'°'},
|
||||
'relative_humidity': {'icon':'💧','unit':'%'},
|
||||
'barometric_pressure': {'icon':'⮇ ','unit': 'hPa'},
|
||||
'lux': {'icon':'🔦 ','unit': 'lx'},
|
||||
'uv_lux': {'icon':'uv🔦 ','unit': 'lx'},
|
||||
'wind_speed': {'icon':'💨 ','unit': 'm/s'},
|
||||
'wind_direction': {'icon':'⮆ ','unit': ''},
|
||||
'battery_level': {'icon':'🔋 ', 'unit':'%'},
|
||||
'voltage': {'icon':'', 'unit':'V'},
|
||||
'channel_utilization': {'icon':'ChUtil:', 'unit':'%'},
|
||||
'air_util_tx': {'icon':'AirUtil:', 'unit':'%'},
|
||||
'uptime_seconds': {'icon':'🆙 ', 'unit':'h'},
|
||||
'latitude_i': {'icon':'🌍 ', 'unit':''},
|
||||
'longitude_i': {'icon':'', 'unit':''},
|
||||
'altitude': {'icon':'⬆️ ', 'unit':'m'},
|
||||
'time': {'icon':'🕔 ', 'unit':''}
|
||||
}
|
||||
|
||||
def humanize_wind_direction(degrees):
|
||||
""" Convert degrees to Eest-West-Nnoth-Ssouth directions """
|
||||
if not 0 <= degrees <= 360:
|
||||
return None
|
||||
|
||||
directions = [
|
||||
("N", 337.5, 22.5),
|
||||
("NE", 22.5, 67.5),
|
||||
("E", 67.5, 112.5),
|
||||
("SE", 112.5, 157.5),
|
||||
("S", 157.5, 202.5),
|
||||
("SW", 202.5, 247.5),
|
||||
("W", 247.5, 292.5),
|
||||
("NW", 292.5, 337.5),
|
||||
]
|
||||
|
||||
if degrees >= directions[0][1] or degrees < directions[0][2]:
|
||||
return directions[0][0]
|
||||
|
||||
# Check for all other directions
|
||||
for direction, lower_bound, upper_bound in directions[1:]:
|
||||
if lower_bound <= degrees < upper_bound:
|
||||
return direction
|
||||
|
||||
# This part should ideally not be reached with valid input
|
||||
return None
|
||||
|
||||
def get_chunks(data):
|
||||
""" Breakdown telemetry data and assign emojis for more visual appeal of the payloads """
|
||||
reading = data.split('\n')
|
||||
|
||||
# remove empty list lefover from the split
|
||||
reading = list(filter(None, reading))
|
||||
parsed=""
|
||||
|
||||
for item in reading:
|
||||
key, value = item.split(":")
|
||||
|
||||
# If value is float, round it to the 1 digit after point
|
||||
# else make it int
|
||||
if "." in value:
|
||||
value = round(float(value.strip()),1)
|
||||
else:
|
||||
try:
|
||||
value = int(value.strip())
|
||||
except Exception:
|
||||
# Leave it string as last resort
|
||||
value = value
|
||||
|
||||
match key:
|
||||
# convert seconds to hours, for our sanity
|
||||
case "uptime_seconds":
|
||||
value = round(value / 60 / 60, 1)
|
||||
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
|
||||
# truncate to 6th digit after floating point, which would be still accurate
|
||||
case "longitude_i" | "latitude_i":
|
||||
value = round(value * 1e-7, 6)
|
||||
# Convert wind direction from degrees to abbreviation
|
||||
case "wind_direction":
|
||||
value = humanize_wind_direction(value)
|
||||
case "time":
|
||||
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
|
||||
|
||||
if key in sensors:
|
||||
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "
|
||||
else:
|
||||
# just pass through if we haven't added the particular telemetry key:value to the sensor dict
|
||||
parsed+=f"{key}:{value} "
|
||||
return parsed
|
||||
@@ -1,22 +1,15 @@
|
||||
import contact.globals as globals
|
||||
import datetime
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from meshtastic import protocols
|
||||
from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2
|
||||
from meshtastic.protobuf import config_pb2
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import ui_state, interface_state
|
||||
import contact.utilities.telemetry_beautifier as tb
|
||||
|
||||
|
||||
def get_channels():
|
||||
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
|
||||
node = interface_state.interface.getNode("^local")
|
||||
"""Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
|
||||
node = globals.interface.getNode('^local')
|
||||
device_channels = node.channels
|
||||
|
||||
# Clear and rebuild channel list
|
||||
# ui_state.channel_list = []
|
||||
# globals.channel_list = []
|
||||
|
||||
for device_channel in device_channels:
|
||||
if device_channel.role:
|
||||
@@ -27,75 +20,66 @@ def get_channels():
|
||||
# If channel name is blank, use the modem preset
|
||||
lora_config = node.localConfig.lora
|
||||
modem_preset_enum = lora_config.modem_preset
|
||||
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[
|
||||
modem_preset_enum
|
||||
].name
|
||||
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
|
||||
channel_name = convert_to_camel_case(modem_preset_string)
|
||||
|
||||
# Add channel to ui_state.channel_list if not already present
|
||||
if channel_name not in ui_state.channel_list:
|
||||
ui_state.channel_list.append(channel_name)
|
||||
# Add channel to globals.channel_list if not already present
|
||||
if channel_name not in globals.channel_list:
|
||||
globals.channel_list.append(channel_name)
|
||||
|
||||
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
|
||||
if channel_name not in ui_state.all_messages:
|
||||
ui_state.all_messages[channel_name] = []
|
||||
# Initialize globals.all_messages[channel_name] if it doesn't exist
|
||||
if channel_name not in globals.all_messages:
|
||||
globals.all_messages[channel_name] = []
|
||||
|
||||
return ui_state.channel_list
|
||||
|
||||
return globals.channel_list
|
||||
|
||||
def get_node_list():
|
||||
if interface_state.interface.nodes:
|
||||
my_node_num = interface_state.myNodeNum
|
||||
if globals.interface.nodes:
|
||||
my_node_num = globals.myNodeNum
|
||||
|
||||
def node_sort(node):
|
||||
if config.node_sort == "lastHeard":
|
||||
return -node["lastHeard"] if ("lastHeard" in node and isinstance(node["lastHeard"], int)) else 0
|
||||
elif config.node_sort == "name":
|
||||
return node["user"]["longName"]
|
||||
elif config.node_sort == "hops":
|
||||
return node["hopsAway"] if "hopsAway" in node else 100
|
||||
if(config.node_sort == 'lastHeard'):
|
||||
return -node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0
|
||||
elif(config.node_sort == "name"):
|
||||
return node['user']['longName']
|
||||
elif(config.node_sort == "hops"):
|
||||
return node['hopsAway'] if 'hopsAway' in node else 100
|
||||
else:
|
||||
return node
|
||||
|
||||
sorted_nodes = sorted(interface_state.interface.nodes.values(), key=node_sort)
|
||||
sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort)
|
||||
|
||||
# Move favorite nodes to the beginning
|
||||
sorted_nodes = sorted(
|
||||
sorted_nodes, key=lambda node: node["isFavorite"] if "isFavorite" in node else False, reverse=True
|
||||
)
|
||||
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isFavorite'] if 'isFavorite' in node else False, reverse = True)
|
||||
|
||||
# Move ignored nodes to the end
|
||||
sorted_nodes = sorted(sorted_nodes, key=lambda node: node["isIgnored"] if "isIgnored" in node else False)
|
||||
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isIgnored'] if 'isIgnored' in node else False)
|
||||
|
||||
node_list = [node["num"] for node in sorted_nodes if node["num"] != my_node_num]
|
||||
node_list = [node['num'] for node in sorted_nodes if node['num'] != my_node_num]
|
||||
return [my_node_num] + node_list # Ensuring your node is always first
|
||||
return []
|
||||
|
||||
|
||||
def refresh_node_list():
|
||||
new_node_list = get_node_list()
|
||||
if new_node_list != ui_state.node_list:
|
||||
ui_state.node_list = new_node_list
|
||||
if new_node_list != globals.node_list:
|
||||
globals.node_list = new_node_list
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_nodeNum():
|
||||
myinfo = interface_state.interface.getMyNodeInfo()
|
||||
myNodeNum = myinfo["num"]
|
||||
myinfo = globals.interface.getMyNodeInfo()
|
||||
myNodeNum = myinfo['num']
|
||||
return myNodeNum
|
||||
|
||||
|
||||
def decimal_to_hex(decimal_number):
|
||||
return f"!{decimal_number:08x}"
|
||||
|
||||
|
||||
def convert_to_camel_case(string):
|
||||
words = string.split("_")
|
||||
camel_case_string = "".join(word.capitalize() for word in words)
|
||||
words = string.split('_')
|
||||
camel_case_string = ''.join(word.capitalize() for word in words)
|
||||
return camel_case_string
|
||||
|
||||
|
||||
def get_time_val_units(time_delta):
|
||||
value = 0
|
||||
unit = ""
|
||||
@@ -123,13 +107,11 @@ def get_time_val_units(time_delta):
|
||||
unit = "s"
|
||||
return (value, unit)
|
||||
|
||||
|
||||
def get_readable_duration(seconds):
|
||||
delta = datetime.timedelta(seconds=seconds)
|
||||
delta = datetime.timedelta(seconds = seconds)
|
||||
val, units = get_time_val_units(delta)
|
||||
return f"{val} {units}"
|
||||
|
||||
|
||||
def get_time_ago(timestamp):
|
||||
now = datetime.datetime.now()
|
||||
dt = datetime.datetime.fromtimestamp(timestamp)
|
||||
@@ -140,80 +122,3 @@ def get_time_ago(timestamp):
|
||||
return f"{value} {unit} ago"
|
||||
return "now"
|
||||
|
||||
|
||||
def add_new_message(channel_id, prefix, message):
|
||||
if channel_id not in ui_state.all_messages:
|
||||
ui_state.all_messages[channel_id] = []
|
||||
|
||||
# Timestamp handling
|
||||
current_timestamp = time.time()
|
||||
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
|
||||
|
||||
# Retrieve the last timestamp if available
|
||||
channel_messages = ui_state.all_messages[channel_id]
|
||||
if channel_messages:
|
||||
# Check the last entry for a timestamp
|
||||
for entry in reversed(channel_messages):
|
||||
if entry[0].startswith("--"):
|
||||
last_hour = entry[0].strip("- ").strip()
|
||||
break
|
||||
else:
|
||||
last_hour = None
|
||||
else:
|
||||
last_hour = None
|
||||
|
||||
# Add a new timestamp if it's a new hour
|
||||
if last_hour != current_hour:
|
||||
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
|
||||
|
||||
# Add the message
|
||||
ts_str = time.strftime("[%H:%M:%S] ")
|
||||
ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message))
|
||||
|
||||
|
||||
def parse_protobuf(packet: dict) -> Union[str, dict]:
|
||||
"""Attempt to parse a decoded payload using the registered protobuf handler."""
|
||||
try:
|
||||
decoded = packet.get("decoded") or {}
|
||||
portnum = decoded.get("portnum")
|
||||
payload = decoded.get("payload")
|
||||
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
|
||||
# These portnumbers carry information visible elswhere in the app, so we just note them in the logs
|
||||
if portnum == "TEXT_MESSAGE_APP":
|
||||
return "✉️"
|
||||
elif portnum == "NODEINFO_APP":
|
||||
return "Name identification payload"
|
||||
elif portnum == "TRACEROUTE_APP":
|
||||
return "Traceroute payload"
|
||||
|
||||
handler = protocols.get(portnums_pb2.PortNum.Value(portnum)) if portnum is not None else None
|
||||
if handler is not None and handler.protobufFactory is not None:
|
||||
try:
|
||||
pb = handler.protobufFactory()
|
||||
pb.ParseFromString(bytes(payload))
|
||||
|
||||
# If we have position payload
|
||||
if portnum == "POSITION_APP":
|
||||
return tb.get_chunks(str(pb))
|
||||
|
||||
# Part of TELEMETRY_APP portnum
|
||||
if hasattr(pb, "device_metrics") and pb.HasField("device_metrics"):
|
||||
return tb.get_chunks(str(pb.device_metrics))
|
||||
|
||||
# Part of TELEMETRY_APP portnum
|
||||
if hasattr(pb, "environment_metrics") and pb.HasField("environment_metrics"):
|
||||
return tb.get_chunks(str(pb.environment_metrics))
|
||||
|
||||
# For other data, without implemented beautification, fallback to just printing the object
|
||||
return str(pb).replace("\n", " ").replace("\r", " ").strip()
|
||||
|
||||
except DecodeError:
|
||||
return payload
|
||||
|
||||
# return payload
|
||||
|
||||
except Exception:
|
||||
return payload
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
validation_rules = {
|
||||
"shortName": {"max_length": 4},
|
||||
"longName": {"max_length": 32},
|
||||
"fixed_pin": {"min_length": 6, "max_length": 6},
|
||||
"position_flags": {"max_length": 3},
|
||||
"enabled_protocols": {"max_value": 2},
|
||||
"hop_limit": {"max_value": 7},
|
||||
"latitude": {"min_value": -90, "max_value": 90},
|
||||
"longitude": {"min_value": -180, "max_value": 180},
|
||||
"altitude": {"min_value": -4294967295, "max_value": 4294967295},
|
||||
"red": {"max_value": 255},
|
||||
"green": {"max_value": 255},
|
||||
"blue": {"max_value": 255},
|
||||
"current": {"max_value": 255},
|
||||
"position_precision": {"max_value": 32},
|
||||
}
|
||||
|
||||
|
||||
def get_validation_for(key: str) -> dict:
|
||||
for rule_key, config in validation_rules.items():
|
||||
if rule_key in key:
|
||||
return config
|
||||
return {}
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.4.5"
|
||||
version = "1.3.4"
|
||||
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
||||
authors = [
|
||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||
|
||||
Reference in New Issue
Block a user