forked from iarv/contact
Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb57b9420 | ||
|
|
e305bb4464 | ||
|
|
636b27cf9b | ||
|
|
8e500cb305 | ||
|
|
0878937194 | ||
|
|
ac2016322b | ||
|
|
031d74a290 | ||
|
|
14913ce5ae | ||
|
|
c9e39d89b0 | ||
|
|
dc27e9e02f | ||
|
|
4f64131d2e | ||
|
|
a55d68a828 | ||
|
|
bd41870567 | ||
|
|
5a722cbf7d | ||
|
|
9cbc2d51f8 | ||
|
|
5ce3e62fdb | ||
|
|
5628758de0 | ||
|
|
890a3b6dc4 | ||
|
|
db01d241c7 | ||
|
|
9044d8d380 | ||
|
|
0288a1d190 | ||
|
|
3674afc216 | ||
|
|
da24902bd0 | ||
|
|
f9bc7f9be9 | ||
|
|
ffd28c02a3 | ||
|
|
d22b3abc2f | ||
|
|
3c9b81f391 | ||
|
|
ecc360dba9 | ||
|
|
696370308f | ||
|
|
5999deac1a | ||
|
|
492c1d30d6 | ||
|
|
9e3b684a5f | ||
|
|
25f388ed23 | ||
|
|
07fbdb92e3 | ||
|
|
7c4cc1dd2f | ||
|
|
06ce9f7ac2 | ||
|
|
f4115e48ad | ||
|
|
857d8d0c04 | ||
|
|
ec0554df14 | ||
|
|
372204a684 | ||
|
|
8ff55c3de9 | ||
|
|
d9088ccd68 | ||
|
|
db8496b2e3 | ||
|
|
2b0f6515af | ||
|
|
4cd7c4e24d | ||
|
|
92a30ad8b0 | ||
|
|
2960553fef | ||
|
|
37c5a7dbc3 | ||
|
|
25240f211b | ||
|
|
c236a386a5 | ||
|
|
66a9954149 | ||
|
|
3df012dd69 | ||
|
|
6d204581ce | ||
|
|
3be31698df | ||
|
|
140d794213 | ||
|
|
8e6edf8e83 | ||
|
|
c97942d35d | ||
|
|
b9cecaea31 | ||
|
|
4bc1654eed | ||
|
|
ee5f2fa4d4 | ||
|
|
04381585ab | ||
|
|
3fc0495fb1 | ||
|
|
1ccd337b35 | ||
|
|
02034e5821 | ||
|
|
6477d8aeea | ||
|
|
eb70e591ae | ||
|
|
8190bdaafa | ||
|
|
50c07827f1 | ||
|
|
c43d014417 | ||
|
|
5f88d0e6fc | ||
|
|
fac5c690ae | ||
|
|
aa8a66ef22 | ||
|
|
498be2c859 | ||
|
|
b086125962 | ||
|
|
f644b92356 | ||
|
|
3db44f4ae3 | ||
|
|
8c837e68a0 | ||
|
|
5dd06624e3 | ||
|
|
c83ccea4ef | ||
|
|
d7f0bee54c | ||
|
|
fb60773ae6 | ||
|
|
47ab0a5b9a | ||
|
|
989c3cf44e | ||
|
|
71aeae4f92 | ||
|
|
34cd21b323 | ||
|
|
e69c51f9c3 | ||
|
|
3c3bf0ad37 | ||
|
|
804f82cbe6 | ||
|
|
57042d2050 | ||
|
|
8342753c51 | ||
|
|
5690329b06 | ||
|
|
a080af3e84 | ||
|
|
dd11932a53 | ||
|
|
dae71984bc | ||
|
|
3668d47119 | ||
|
|
fe3980bc5a | ||
|
|
9c380c18fd | ||
|
|
30d14a6a9e | ||
|
|
bbfe361173 | ||
|
|
0d6f234191 | ||
|
|
16c8e3032a | ||
|
|
611d59fefe | ||
|
|
651d381c78 | ||
|
|
e7850b9204 | ||
|
|
4306971871 | ||
|
|
ba86108316 | ||
|
|
83393e2a25 | ||
|
|
9073da802d | ||
|
|
5907807b71 | ||
|
|
cc7124b6f5 | ||
|
|
353412be11 | ||
|
|
8382da07a3 | ||
|
|
01cfe4c681 | ||
|
|
1675b0a116 | ||
|
|
b717d46441 | ||
|
|
c100539ff9 | ||
|
|
5e1ede0bea | ||
|
|
d911176603 | ||
|
|
586724662d | ||
|
|
313c13a96a | ||
|
|
1dc0fc1f2e | ||
|
|
84dd99fc40 | ||
|
|
03328e4115 | ||
|
|
2d03f2c60c | ||
|
|
e462530930 | ||
|
|
7560b0805a | ||
|
|
b5a841d7d2 | ||
|
|
fe625efd5f | ||
|
|
25b3fc427b | ||
|
|
21e7e01703 | ||
|
|
07ce9dfbac | ||
|
|
bae197eeca | ||
|
|
d0c67f0864 | ||
|
|
6e96023906 | ||
|
|
f5b9db6d7a | ||
|
|
40c2ef62b4 | ||
|
|
d019c6371c | ||
|
|
c62965a94f | ||
|
|
a4a15e57b4 | ||
|
|
9621bb09b3 | ||
|
|
3cb265ca13 | ||
|
|
ba03f7af7e | ||
|
|
3d3d628483 | ||
|
|
466f385c31 | ||
|
|
aa2d3bded4 | ||
|
|
5dea39ae50 | ||
|
|
0464e44e0d |
143
.github/workflows/release.yaml
vendored
Normal file
143
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+a[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+b[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
|
||||
|
||||
env:
|
||||
PACKAGE_NAME: "contact"
|
||||
OWNER: "pdxlocations"
|
||||
|
||||
jobs:
|
||||
details:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.release.outputs.new_version }}
|
||||
suffix: ${{ steps.release.outputs.suffix }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Extract tag and Details
|
||||
id: release
|
||||
run: |
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}')
|
||||
SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "")
|
||||
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "Version is $NEW_VERSION"
|
||||
echo "Suffix is $SUFFIX"
|
||||
echo "Tag name is $TAG_NAME"
|
||||
else
|
||||
echo "No tag found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_pypi:
|
||||
needs: details
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch information from PyPI
|
||||
run: |
|
||||
response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}")
|
||||
latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last")
|
||||
if [ -z "$latest_previous_version" ]; then
|
||||
echo "Package not found on PyPI."
|
||||
latest_previous_version="0.0.0"
|
||||
fi
|
||||
echo "Latest version on PyPI: $latest_previous_version"
|
||||
echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Compare versions and exit if not newer
|
||||
run: |
|
||||
NEW_VERSION=${{ needs.details.outputs.new_version }}
|
||||
LATEST_VERSION=$latest_previous_version
|
||||
if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then
|
||||
echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI."
|
||||
exit 1
|
||||
else
|
||||
echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI."
|
||||
fi
|
||||
|
||||
setup_and_build:
|
||||
needs: [details, check_pypi]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Set project version with Poetry
|
||||
run: |
|
||||
poetry version ${{ needs.details.outputs.new_version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: poetry install --sync --no-interaction
|
||||
|
||||
- name: Build source and wheel distribution
|
||||
run: |
|
||||
poetry build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
pypi_publish:
|
||||
name: Upload release to PyPI
|
||||
needs: [setup_and_build, details]
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- name: Publish distribution to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
github_release:
|
||||
name: Create GitHub Release
|
||||
needs: [setup_and_build, details]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,4 +7,5 @@ client.db
|
||||
client.log
|
||||
settings.log
|
||||
config.json
|
||||
default_config.log
|
||||
default_config.log
|
||||
dist/
|
||||
13
.vscode/launch.json
vendored
Normal file
13
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": []
|
||||
}
|
||||
]
|
||||
}
|
||||
50
README.md
50
README.md
@@ -1,13 +1,41 @@
|
||||
## Contact - A Console UI for Meshtastic
|
||||
Powered by Meshtastic.org
|
||||
### (Formerly Curses Client for Meshtastic)
|
||||
|
||||
<img width="846" alt="Screenshot_2024-03-29_at_4 00 29_PM" src="https://github.com/pdxlocations/meshtastic-curses-client/assets/117498748/e99533b7-5c0c-463d-8d5f-6e3cccaeced7">
|
||||
#### Powered by Meshtastic.org
|
||||
|
||||
This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores.
|
||||
|
||||
|
||||
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
|
||||
|
||||
<br><br>
|
||||
Settings can be accessed within the client or can be run standalone
|
||||
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="509" alt="Screenshot 2024-04-15 at 3 39 12 PM" src="https://github.com/pdxlocations/meshtastic-curses-client/assets/117498748/37bc57db-fe2d-4ba4-adc8-679b4cb642f9">
|
||||
<img width="441" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dd47f52a-d4d8-4e40-8001-9ea53d87f816" />
|
||||
|
||||
## Message Persistence
|
||||
|
||||
All messages will saved in a SQLite DB and restored upon relaunch of the app. You may delete `client.db` if you wish to erase all stored messages and node data. If multiple nodes are used, each will independently store data in the database, but the data will not be shared or viewable between nodes.
|
||||
|
||||
## Client Configuration
|
||||
|
||||
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
|
||||
|
||||
## Commands
|
||||
|
||||
- `↑→↓←` = Navigate around the UI.
|
||||
- `ENTER` = Send a message typed in the Input Window, or with the Node List highlighted, select a node to DM
|
||||
- `` ` `` = Open the Settings dialogue
|
||||
- `CTRL` + `p` = Hide/show a log of raw received packets.
|
||||
- `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.
|
||||
- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed.
|
||||
|
||||
### Search
|
||||
- Press `CTRL` + `/` while the nodes or channels window is highlighted to start search
|
||||
- Type text to search as you type, first matching item will be selected, starting at current selected index
|
||||
- Press Tab to find next match starting from the current index - search wraps around if necessary
|
||||
- Press Esc or Enter to exit search mode
|
||||
|
||||
## Arguments
|
||||
|
||||
@@ -18,14 +46,20 @@ You can pass the following arguments to the client:
|
||||
Optional arguments to specify a device to connect to and how.
|
||||
|
||||
- `--port`, `--serial`, `-s`: The port to connect to via serial, e.g. `/dev/ttyUSB0`.
|
||||
- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP.
|
||||
- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed.
|
||||
- `--ble`, `-b`: The BLE device MAC address or name to connect to.
|
||||
- `--settings`, `--set`, `--control`, `-c`: Launch directly into the settings.
|
||||
|
||||
If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```sh
|
||||
python main.py --port /dev/ttyUSB0
|
||||
python main.py --host 192.168.1.1
|
||||
python main.py --ble BlAddressOfDevice
|
||||
contact --port /dev/ttyUSB0
|
||||
contact --host 192.168.1.1
|
||||
contact --ble BlAddressOfDevice
|
||||
```
|
||||
To quickly connect to localhost, use:
|
||||
```sh
|
||||
contact -t
|
||||
```
|
||||
114
contact/__main__.py
Normal file
114
contact/__main__.py
Normal file
@@ -0,0 +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.
|
||||
'''
|
||||
|
||||
import contextlib
|
||||
import curses
|
||||
import os
|
||||
from pubsub import pub
|
||||
import sys
|
||||
import io
|
||||
import logging
|
||||
import subprocess
|
||||
import traceback
|
||||
import threading
|
||||
|
||||
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.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.interfaces import initialize_interface
|
||||
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, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
globals.lock = threading.Lock()
|
||||
|
||||
def main(stdscr):
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
|
||||
setup_colors()
|
||||
draw_splash(stdscr)
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# 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 %s", args)
|
||||
with globals.lock:
|
||||
globals.interface = initialize_interface(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)
|
||||
|
||||
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")
|
||||
|
||||
main_ui(stdscr)
|
||||
|
||||
except Exception as e:
|
||||
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()
|
||||
@@ -1,8 +1,9 @@
|
||||
interface = None
|
||||
lock = None
|
||||
display_log = False
|
||||
all_messages = {}
|
||||
channel_list = []
|
||||
notifications = set()
|
||||
notifications = []
|
||||
packet_buffer = []
|
||||
node_list = []
|
||||
myNodeNum = 0
|
||||
315
contact/localisations/en.ini
Normal file
315
contact/localisations/en.ini
Normal file
@@ -0,0 +1,315 @@
|
||||
#field_name, "Human readable field name with first word capitalized", "Help text with [warning]warnings[/warning], [note]notes[/note], [underline]underlines[/underline], \033[31mANSI color codes\033[0m and \nline breaks."
|
||||
[User Settings]
|
||||
user, "User"
|
||||
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
|
||||
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
|
||||
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
|
||||
|
||||
[Channels.channel]
|
||||
title, "Channels"
|
||||
channel_num, "Channel number", "The index number of this channel."
|
||||
psk, "PSK", "The channel's encryption key."
|
||||
name, "Name", "The channel's name."
|
||||
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."
|
||||
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", ""
|
||||
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."
|
||||
node_info_broadcast_secs, "Nodeinfo broadcast interval", "This is the number of seconds between NodeInfo message broadcasts. Will also send a nodeinfo in response to new nodes on the mesh."
|
||||
double_tap_as_button_press, "Double tap as button press", "This option will enable a double tap, when a supported accelerometer is attached to the device, to be treated as a button press."
|
||||
is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note] Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]"
|
||||
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."
|
||||
|
||||
[config.position]
|
||||
title, "Position"
|
||||
position_broadcast_secs, "Position broadcast interval", "If smart broadcast is off we should send our position this often."
|
||||
position_broadcast_smart_enabled, "Smart position broadcast enabled", "Smart broadcast will send out your position at an increased frequency only if your location has changed enough for a position update to be useful."
|
||||
fixed_position, "Fixed position", "If set, this use a fixed position. The device will generate GPS updates but use whatever the last lat/lon/alt it saved for the node. Position can be set by an internal GPS or with smartphone GPS."
|
||||
latitude, "Latitude", ""
|
||||
longitude, "Longitude", ""
|
||||
altitude, "Altitude", ""
|
||||
gps_enabled, "GPS enabled", ""
|
||||
gps_update_interval, "GPS update interval", "How often we should try to get GPS position (in seconds), or zero for the default of once every 2 minutes, or a very large value (maxint) to update only once at boot."
|
||||
gps_attempt_time, "GPS attempt time", ""
|
||||
position_flags, "Position flags", "See Meshtastic docs for more information."
|
||||
rx_gpio, "GPS RX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the RX pin of a GPS module."
|
||||
tx_gpio, "GPS TX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the TX pin of a GPS module."
|
||||
broadcast_smart_minimum_distance, "GPS smart position min distance", "The minimum distance in meters traveled (since the last send) before we can send a position to the mesh if smart broadcast is enabled."
|
||||
broadcast_smart_minimum_interval_secs, "GPS smart position min interval", "The minimum number of seconds (since the last send) before we can send a position to the mesh if smart broadcast is enabled."
|
||||
gps_en_gpio, "GPS enable GPIO", ""
|
||||
gps_mode, "GPS mode", "Configures whether the GPS functionality is enabled, disabled, or not present on the node."
|
||||
|
||||
[config.power]
|
||||
title, "Power"
|
||||
is_power_saving, "Enable power saving mode", "Automatically shut down a device after this many seconds if power is lost."
|
||||
on_battery_shutdown_after_secs, "Battery shutdown interval", ""
|
||||
adc_multiplier_override, "ADC multiplier override", "Ratio of voltage divider for battery pin. Overrides the ADC_MULTIPLIER defined in the firmware device variant file for battery voltage calculation. See Meshtastic docs for more info."
|
||||
wait_bluetooth_secs, "Bluetooth", "How long to wait before turning off BLE when no bluetooth device is connected."
|
||||
sds_secs, "Super deep sleep interval", "While in Light Sleep if mesh_sds_timeout_secs is exceeded we will lower into super deep sleep for this value or a button press. 0 for default of one year"
|
||||
ls_secs, "Light sleep interval", "ESP32 only. In light sleep the CPU is suspended, LoRa radio is on, BLE is off and GPS is on."
|
||||
min_wake_secs, "Minimum wake interval", "While in light sleep when we receive packets on the LoRa radio we will wake and handle them and stay awake in no Bluetooth mode for this interval in seconds."
|
||||
device_battery_ina_address, "Device battery INA2xx address", "If an INA-2XX device is auto-detected on one of the I2C buses at the specified address, it will be used as the authoritative source for reading device battery level voltage. Setting is ignored for devices with PMUs (e.g. T-beams)"
|
||||
powermon_enables, "Power monitor enables", "If non-zero, we want powermon log outputs. With the particular (bitfield) sources enabled."
|
||||
|
||||
[config.network]
|
||||
title, "Network"
|
||||
wifi_enabled, "Wi-Fi enabled", "Enables or Disables Wi-Fi."
|
||||
wifi_ssid, "Wi-Fi SSID", "This is your Wi-Fi Network's SSID."
|
||||
wifi_psk, "Wi-Fi PSK", "This is your Wi-Fi Network's password."
|
||||
ntp_server, "NTP server", "The network time server used if IP networking is available."
|
||||
eth_enabled, "Ethernet enabled", "Enables or Disables Ethernet on some hardware models."
|
||||
address_mode, "IPv4 networking mode", "Set to DHCP by default. Change to STATIC to use a static IP address. Applies to both Ethernet and Wi-Fi."
|
||||
ipv4_config, "IPv4 configuration", "Advanced network settings"
|
||||
ip, "IPv4 static address", ""
|
||||
gateway, "IPv4 gateway", ""
|
||||
subnet, "IPv4 subnet", ""
|
||||
dns, "IPv4 DNS server", ""
|
||||
rsyslog_server, "RSyslog server", ""
|
||||
enabled_protocols, "Enabled protocols", ""
|
||||
|
||||
[config.network.ipv4_config]
|
||||
title, "IPv4 Config", ""
|
||||
ip, "IP", ""
|
||||
gateway, "Gateway", ""
|
||||
subnet, "Subnet", ""
|
||||
dns, "DNS", ""
|
||||
|
||||
[config.display]
|
||||
title, "Display"
|
||||
screen_on_secs, "Screen on duration", "How long the screen remains on in seconds after the user button is pressed or messages are received."
|
||||
gps_format, "GPS format", "The format used to display GPS coordinates on the device screen."
|
||||
auto_screen_carousel_secs, "Auto carousel interval", "Automatically toggles to the next page on the screen like a carousel, based on the specified interval in seconds."
|
||||
compass_north_top, "Always point north", "If set, compass heading on screen outside of the circle will always point north. This feature is off by default and the top of display represents your heading direction, the North indicator will move around the circle."
|
||||
flip_screen, "Flip screen", "Whether to flip the screen vertically."
|
||||
units, "Preferred display units", "Switch between METRIC (default) and IMPERIAL units."
|
||||
oled, "OLED definition", "The type of OLED Controller is auto-detected by default, but can be defined with this setting if the auto-detection fails. For the SH1107, we assume a square display with 128x128 Pixels like the GME128128-1."
|
||||
displaymode, "Display mode", "DEFAULT, TWOCOLOR, INVERTED or COLOR. TWOCOLOR: intended for OLED displays with first line a different color. INVERTED: will invert bicolor area, resulting in white background headline on monochrome displays."
|
||||
heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' or 'TWOCOLOR' display mode is used. This setting will make the heading bold, so it is easier to read."
|
||||
wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button."
|
||||
compass_orientation, "Compass orientation", "Whether to rotate the compass."
|
||||
use_12h_clock, "Use 12 hour clock"
|
||||
|
||||
[config.device_ui]
|
||||
title, "Device UI"
|
||||
version, "Version", ""
|
||||
screen_brightness, "Screen brightness", ""
|
||||
screen_timeout, "Screen timeout", ""
|
||||
screen_lock, "Screen lock", ""
|
||||
settings_lock, "Settings lock", ""
|
||||
pin_code, "PIN code", ""
|
||||
theme, "Theme", ""
|
||||
alert_enabled, "Alert enabled", ""
|
||||
banner_enabled, "Banner enabled", ""
|
||||
ring_tone_id, "Ring tone ID", ""
|
||||
language, "Language", ""
|
||||
node_filter, "Node Filter", ""
|
||||
node_highlight, "Node Highlight", ""
|
||||
calibration_data, "Calibration Data", ""
|
||||
map_data, "Map Data", ""
|
||||
|
||||
[config.device_ui.node_filter]
|
||||
title, "Node Filter"
|
||||
unknown_switch, "Unknown Switch", ""
|
||||
offline_switch, "Offline Switch", ""
|
||||
public_key_switch, "Public Key Switch", ""
|
||||
hops_away, "Hops Away", ""
|
||||
position_switch, "Position Switch", ""
|
||||
node_name, "Node Name", ""
|
||||
channel, "Channel", ""
|
||||
|
||||
[config.device_ui.node_highlight]
|
||||
title, "Node Highlight"
|
||||
chat_switch, "Chat Switch", ""
|
||||
position_switch, "Position Switch", ""
|
||||
telemetry_switch, "Telemetry Switch", ""
|
||||
iaq_switch, "IAQ Switch", ""
|
||||
node_name, "Node Name", ""
|
||||
|
||||
[config.device_ui.map_data]
|
||||
title, "Map Data"
|
||||
home, "Home", ""
|
||||
style, "Style", ""
|
||||
follow_gps, "Follow GPS", ""
|
||||
|
||||
[config.lora]
|
||||
title, "LoRa"
|
||||
use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset."
|
||||
modem_preset, "Preset", "The default preset will provide a strong mixture of speed and range, for most users."
|
||||
bandwidth, "Bandwidth", "Width of the frequency 'band' used around the calculated center frequency. Only used if modem preset is disabled."
|
||||
spread_factor, "Spread factor", "Indicates the number of chirps per symbol. Only used if modem preset is disabled."
|
||||
coding_rate, "Coding rate", "The proportion of each LoRa transmission that contains actual data - the rest is used for error correction."
|
||||
frequency_offset, "Frequency offset", "This parameter is for advanced users with advanced test equipment."
|
||||
region, "Region", "Sets the region for your node. As long as this is not set, the node will display a message and not transmit any packets."
|
||||
hop_limit, "Hop limit", "The maximum number of intermediate nodes between our node and a node it is sending to. Does not impact received messages.\n[warning]Excessive hop limit increases congestion![/warning]\nMust be between 0-7."
|
||||
tx_enabled, "Enable TX", "Enables/disables the radio chip. Useful for hot-swapping antennas."
|
||||
tx_power, "TX power in dBm", "[warning]Setting a 33db radio above 8db will permanently damage it. ERP above 27db violates EU law. ERP above 36db violates US (unlicensed) law.[/warning] If 0, will use the max continuous power legal in region. Must be 0-30 (0=automatic)."
|
||||
channel_num, "Frequency slot", "Determines the exact frequency the radio transmits and receives. If unset or set to 0, determined automatically by the primary channel name."
|
||||
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, "", ""
|
||||
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."
|
||||
|
||||
[config.bluetooth]
|
||||
title, "Bluetooth"
|
||||
enabled, "Enabled", "Enables bluetooth. Duh!"
|
||||
mode, "Pairing mode", "RANDOM_PIN generates a random PIN during runtime. FIXED_PIN uses the fixed PIN that should then be additionally specified. Finally, NO_PIN disables PIN authentication."
|
||||
fixed_pin, "Fixed PIN", "If your pairing mode is set to FIXED_PIN, the default value is 123456. For all other pairing modes, this number is ignored. A custom integer (6 digits) can be set via the Bluetooth config options."
|
||||
|
||||
[config.security]
|
||||
title, "Security"
|
||||
public_key, "Public key", "The public key of the device, shared with other nodes on the mesh to allow them to compute a shared secret key for secure communication. Generated automatically to match private key.\n[warning]Don't change this if you don't know what you're doing.[/warning]"
|
||||
private_key, "Private key", "The private key of the device, used to create a shared key with a remote device for secure communication.\n[warning]This key should be kept confidential.[/warning]\n[note]Setting an invalid key will lead to unexpected behaviors.[/note]"
|
||||
is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note]Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]"
|
||||
serial_enabled, "Enable serial console", ""
|
||||
debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active."
|
||||
admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes."
|
||||
admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3."
|
||||
|
||||
[module.mqtt]
|
||||
title, "MQTT"
|
||||
enabled, "Enabled", "Enables the MQTT module."
|
||||
address, "Server address", "The server to use for MQTT. If not set, the default public server will be used."
|
||||
username, "Username", "MQTT Server username to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default public server, this will only be honored if set, otherwise the device will use the default username."
|
||||
password, "Password", "MQTT password to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default server, this will only be honored if set, otherwise the device will use the default password."
|
||||
encryption_enabled, "Encryption enabled", "Whether to send encrypted or unencrypted packets to the MQTT server. Unencrypted packets may be useful for external systems that want to consume meshtastic packets. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set."
|
||||
json_enabled, "JSON enabled", "Enable the sending / consumption of JSON packets on MQTT. These packets are not encrypted, but offer an easy way to integrate with systems that can read JSON. JSON is not supported on the nRF52 platform."
|
||||
tls_enabled, "TLS enabled", "If true, we attempt to establish a secure connection using TLS."
|
||||
root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs."
|
||||
proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings."
|
||||
map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet."
|
||||
map_report_settings.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."
|
||||
|
||||
[module.serial]
|
||||
title, "Serial"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
echo, "Echo", "If set, any packets you send will be echoed back to your device."
|
||||
rxd, "Receive GPIO pin", "Set the GPIO pin to the RXD pin you have set up."
|
||||
txd, "Transmit GPIO pin", "Set the GPIO pin to the TXD pin you have set up."
|
||||
baud, "Baud rate", "The serial baud rate."
|
||||
timeout, "Timeout", "The amount of time to wait before we consider your packet as 'done'."
|
||||
mode, "Mode", "See Meshtastic docs for more information."
|
||||
override_console_serial_port, "Override console serial port", "If set to true, this will allow Serial Module to control (set baud rate) and use the primary USB serial bus for output. This is only useful for NMEA and CalTopo modes and may behave strangely or not work at all in other modes. Setting TX/RX pins in the Serial Module config will cause this setting to be ignored."
|
||||
|
||||
[module.external_notification]
|
||||
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_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."
|
||||
alert_message_buzzer, "Alert buzzer on message", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character."
|
||||
alert_bell, "Alert when receiving a bell (general)", "Specifies if an alert should be triggered when receiving an incoming bell with an alert bell character."
|
||||
alert_bell_vibra, "Alert vibration on bell", "Specifies if a vibration alert should be triggered when receiving an incoming message with an alert bell character."
|
||||
alert_bell_buzzer, "Alert buzzer on bell", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character."
|
||||
use_pwm, "Use PWM for buzzer", ""
|
||||
nag_timeout, "Repeat (nag timeout)", "Specifies if the alert should be repeated. If set to a value greater than zero, the alert will be repeated until the user button is pressed or 'value' number of seconds have past."
|
||||
use_i2s_as_buzzer, "Use i2s as buzzer", ""
|
||||
|
||||
[module.store_forward]
|
||||
title, "Store & Forward"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
heartbeat, "Heartbeat", "The Store & Forward Server sends a periodic message onto the network. This allows connected devices to know that a server is in range and listening to received messages. A client like Android, iOS, or Web can (if supported) indicate to the user whether a Store & Forward Server is available."
|
||||
records, "Records", "Set this to the maximum number of records the server will save. Best to leave this at the default (0) where the module will use 2/3 of your device's available PSRAM. This is about 11,000 records."
|
||||
history_return_max, "History return max", "Sets the maximum number of messages to return to a client device when it requests the history."
|
||||
history_return_window, "History return window", "Limits the time period (in minutes) a client device can request."
|
||||
is_server, "Is server", "Set to true to configure your node with PSRAM as a Store & Forward Server for storing and forwarding messages. This is an alternative to setting the node as a ROUTER and only available since 2.4."
|
||||
|
||||
[module.range_test]
|
||||
title, "Range Test"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages."
|
||||
save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]"
|
||||
|
||||
[module.telemetry]
|
||||
title, "Telemetry"
|
||||
device_update_interval, "Device metrics update interval", "How often we should send Device Metrics over the mesh in seconds."
|
||||
environment_update_interval, "Environment metrics update interval", "How often we should send environment (sensor) Metrics over the mesh in seconds."
|
||||
environment_measurement_enabled, "Environment telemetry enabled", "Enable the Environment Telemetry (Sensors)."
|
||||
environment_screen_enabled, "Environment screen enabled", "Show the environment telemetry data on the device display."
|
||||
environment_display_fahrenheit, "Display fahrenheit", "The sensor is always read in Celsius, but the user can opt to display in Fahrenheit (on the device display only) using this setting."
|
||||
air_quality_enabled, "Air quality enabled", "This option is used to enable/disable the sending of air quality metrics from an attached supported sensor over the mesh network."
|
||||
air_quality_interval, "Air quality interval", "This option is used to configure the interval in seconds that should be used to send air quality metrics from an attached supported sensor over the mesh network in seconds."
|
||||
power_measurement_enabled, "Power metrics enabled", "This option is used to enable/disable the sending of power telemetry as gathered by an attached supported voltage/current sensor. Note that this does not need to be enabled to monitor the voltage of the battery."
|
||||
power_update_interval, "Power metrics interval", "This option is used to configure the interval in seconds that should be used to send power metrics from an attached supported sensor over the mesh network in seconds."
|
||||
power_screen_enabled, "Power screen enabled", "Show the power telemetry data on the device display."
|
||||
health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds."
|
||||
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
|
||||
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
|
||||
|
||||
[module.canned_message]
|
||||
title, "Canned Message"
|
||||
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
|
||||
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
|
||||
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
|
||||
inputbroker_pin_press, "Input broker pin press", "GPIO Pin Value (1-39) For encoder Press port."
|
||||
inputbroker_event_cw, "Input broker event clockwise", "Generate the rotary clockwise event."
|
||||
inputbroker_event_ccw, "Input broker event counter clockwise", "Generate the rotary counter clockwise event."
|
||||
inputbroker_event_press, "Input broker event press", "Generate input event on Press of this kind."
|
||||
updown1_enabled, "Up down encoder enabled", "Enable the up / down encoder."
|
||||
enabled, "Enabled", "Enables the module."
|
||||
allow_input_source, "Input source", "Input event sources accepted by the canned message module."
|
||||
send_bell, "Send bell", "Sends a bell character with each message."
|
||||
|
||||
[module.audio]
|
||||
title, "Audio"
|
||||
codec2_enabled, "Enabled", "Enables the module."
|
||||
ptt_pin, "PTT GPIO", "The GPIO to use for the Push-To-Talk button. The default is GPIO 39 on the ESP32."
|
||||
bitrate, "Audio bitrate/codec mode", "The bitrate to use for audio."
|
||||
i2s_ws, "I2S word select", "The GPIO to use for the WS signal in the I2S interface."
|
||||
i2s_sd, "I2S data IN", "The GPIO to use for the SD signal in the I2S interface."
|
||||
i2s_din, "I2S data OUT", "The GPIO to use for the DIN signal in the I2S interface."
|
||||
i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface."
|
||||
|
||||
[module.remote_hardware]
|
||||
title, "Remote Hardware"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
allow_undefined_pin_access, "Allow undefined pin access", ""
|
||||
|
||||
[module.neighbor_info]
|
||||
title, "Neighbor Info"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
update_interval, "Update interval", "How often in seconds the neighbor info is sent to the mesh. This cannot be set lower than 4 hours (14400 seconds). The default is 6 hours (21600 seconds)."
|
||||
transmit_over_lora, "Transmit over LoRa", "Available from firmware 2.5.13 and higher. By default, neighbor info will only be sent to MQTT and a connected app. If enabled, the neighbor info will be sent on the primary channel over LoRa. Only available when the primary channel is not the public channel with default key and name."
|
||||
|
||||
[module.ambient_lighting]
|
||||
title, "Ambient Lighting"
|
||||
led_state, "LED state", "Sets the LED to on or Off."
|
||||
current, "Current", "Sets the current for the LED output. Default is 10."
|
||||
red, "Red", "Sets the red LED level. Values are 0-255."
|
||||
green, "Green", "Sets the green LED level. Values are 0-255."
|
||||
blue, "Blue", "Sets the blue LED level. Values are 0-255."
|
||||
|
||||
[module.detection_sensor]
|
||||
title, "Detection Sensor"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
minimum_broadcast_secs, "Minimum broadcast interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
|
||||
state_broadcast_secs, "State broadcast interval", "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes, When set to 0, only state changes will be broadcasted, Works as a sort of status heartbeat for peace of mind."
|
||||
send_bell, "Send bell", "Send ASCII bell with alert message. Useful for triggering ext. notification on bell name."
|
||||
name, "Friendly name", "Used to format the message sent to mesh. Example: A name 'Motion' would result in a message 'Motion detected'. Maximum length of 20 characters."
|
||||
monitor_pin, "Monitor pin", "The GPIO pin to monitor for state changes."
|
||||
detection_trigger_type, "Detection triggered high", "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)."
|
||||
use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin."
|
||||
|
||||
[module.paxcounter]
|
||||
title, "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, "", ""
|
||||
ble_threshold, "", ""
|
||||
105
contact/message_handlers/rx_handler.py
Normal file
105
contact/message_handlers/rx_handler.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
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
|
||||
import contact.globals as globals
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
def on_receive(packet, interface):
|
||||
|
||||
with globals.lock:
|
||||
# Update packet log
|
||||
globals.packet_buffer.append(packet)
|
||||
if len(globals.packet_buffer) > 20:
|
||||
# Trim buffer to 20 packets
|
||||
globals.packet_buffer = globals.packet_buffer[-20:]
|
||||
|
||||
if globals.display_log:
|
||||
draw_packetlog_win()
|
||||
try:
|
||||
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):
|
||||
draw_node_list()
|
||||
|
||||
if packet['decoded']['portnum'] == 'NODEINFO_APP':
|
||||
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
|
||||
maybe_store_nodeinfo_in_db(packet)
|
||||
|
||||
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']
|
||||
else:
|
||||
channel_number = 0
|
||||
|
||||
if packet['to'] == globals.myNodeNum:
|
||||
if packet['from'] in globals.channel_list:
|
||||
pass
|
||||
else:
|
||||
globals.channel_list.append(packet['from'])
|
||||
if(packet['from'] not in globals.all_messages):
|
||||
globals.all_messages[packet['from']] = []
|
||||
update_node_info_in_db(packet['from'], chat_archived=False)
|
||||
refresh_channels = True
|
||||
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
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') + ":"
|
||||
|
||||
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(globals.channel_list[channel_number], message_from_id, message_string)
|
||||
|
||||
except KeyError as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
@@ -3,16 +3,16 @@ import google.protobuf.json_format
|
||||
from meshtastic import BROADCAST_NUM
|
||||
from meshtastic.protobuf import mesh_pb2, portnums_pb2
|
||||
|
||||
from db_handler import save_message_to_db, update_ack_nak, get_name_from_database
|
||||
import default_config as config
|
||||
import globals
|
||||
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
|
||||
|
||||
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):
|
||||
from ui.curses_ui import draw_messages_window
|
||||
from contact.ui.contact_ui import draw_messages_window
|
||||
request = packet['decoded']['requestId']
|
||||
if(request not in ack_naks):
|
||||
return
|
||||
@@ -43,7 +43,7 @@ def onAckNak(packet):
|
||||
|
||||
def on_response_traceroute(packet):
|
||||
"""on response for trace route"""
|
||||
from ui.curses_ui import draw_channel_list, draw_messages_window, add_notification
|
||||
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
|
||||
|
||||
refresh_channels = False
|
||||
refresh_messages = False
|
||||
@@ -94,6 +94,9 @@ def on_response_traceroute(packet):
|
||||
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)
|
||||
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
|
||||
70
contact/settings.py
Normal file
70
contact/settings.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import contextlib
|
||||
import curses
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.ui.colors import setup_colors
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.ui.control_ui import set_region, settings_menu
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.interfaces import initialize_interface
|
||||
|
||||
|
||||
def main(stdscr):
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
setup_colors()
|
||||
draw_splash(stdscr)
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
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"])
|
||||
if confirmation == "Yes":
|
||||
set_region(interface)
|
||||
interface.close()
|
||||
interface = initialize_interface(args)
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
settings_menu(stdscr, interface)
|
||||
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
import curses
|
||||
import default_config as config
|
||||
import contact.ui.default_config as config
|
||||
|
||||
COLOR_MAP = {
|
||||
"black": curses.COLOR_BLACK,
|
||||
791
contact/ui/contact_ui.py
Normal file
791
contact/ui/contact_ui.py
Normal file
@@ -0,0 +1,791 @@
|
||||
import curses
|
||||
import textwrap
|
||||
import time
|
||||
import logging
|
||||
import traceback
|
||||
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
|
||||
from contact.settings import settings_menu
|
||||
from contact.message_handlers.tx_handler import send_message, send_traceroute
|
||||
from contact.ui.colors import setup_colors, get_color
|
||||
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
import contact.ui.default_config as config
|
||||
import contact.ui.dialog
|
||||
import contact.globals as globals
|
||||
|
||||
def handle_resize(stdscr, firstrun):
|
||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win
|
||||
|
||||
# Calculate window max dimensions
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Define window dimensions and positions
|
||||
channel_width = 3 * (width // 16)
|
||||
nodes_width = 5 * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
|
||||
if firstrun:
|
||||
entry_win = curses.newwin(3, width, 0, 0)
|
||||
channel_win = curses.newwin(height - 6, channel_width, 3, 0)
|
||||
messages_win = curses.newwin(height - 6, messages_width, 3, channel_width)
|
||||
nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
|
||||
function_win = curses.newwin(3, width, height - 3, 0)
|
||||
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
|
||||
|
||||
# Will be resized to what we need when drawn
|
||||
messages_pad = curses.newpad(1,1)
|
||||
nodes_pad = curses.newpad(1,1)
|
||||
channel_pad = curses.newpad(1,1)
|
||||
|
||||
# Set background colors for windows
|
||||
for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]:
|
||||
win.bkgd(get_color("background"))
|
||||
|
||||
# Set background colors for pads
|
||||
for pad in [messages_pad, nodes_pad, channel_pad]:
|
||||
pad.bkgd(get_color("background"))
|
||||
|
||||
# Set colors for window frames
|
||||
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
|
||||
win.attrset(get_color("window_frame"))
|
||||
|
||||
|
||||
else:
|
||||
for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]:
|
||||
win.erase()
|
||||
|
||||
entry_win.resize(3, width)
|
||||
|
||||
channel_win.resize(height - 6, channel_width)
|
||||
|
||||
messages_win.resize(height - 6, messages_width)
|
||||
messages_win.mvwin(3, channel_width)
|
||||
|
||||
nodes_win.resize(height - 6, nodes_width)
|
||||
nodes_win.mvwin(3, channel_width + messages_width)
|
||||
|
||||
function_win.resize(3, width)
|
||||
function_win.mvwin(height - 3, 0)
|
||||
|
||||
packetlog_win.resize(int(height / 3), messages_width)
|
||||
packetlog_win.mvwin(height - int(height / 3) - 3, channel_width)
|
||||
|
||||
|
||||
# Draw window borders
|
||||
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
|
||||
win.box()
|
||||
win.refresh()
|
||||
|
||||
entry_win.keypad(True)
|
||||
curses.curs_set(1)
|
||||
|
||||
try:
|
||||
draw_function_win()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
draw_node_list()
|
||||
except:
|
||||
# Resize events can come faster than we can re-draw, which can cause a curses error.
|
||||
# In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later.
|
||||
pass
|
||||
|
||||
|
||||
def main_ui(stdscr):
|
||||
global input_text
|
||||
input_text = ""
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
handle_resize(stdscr, True)
|
||||
|
||||
while True:
|
||||
draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
|
||||
# Get user input from entry window
|
||||
char = entry_win.get_wch()
|
||||
|
||||
# draw_debug(f"Keypress: {char}")
|
||||
|
||||
if char == curses.KEY_UP:
|
||||
if globals.current_window == 0:
|
||||
scroll_channels(-1)
|
||||
elif globals.current_window == 1:
|
||||
scroll_messages(-1)
|
||||
elif globals.current_window == 2:
|
||||
scroll_nodes(-1)
|
||||
|
||||
elif char == curses.KEY_DOWN:
|
||||
if globals.current_window == 0:
|
||||
scroll_channels(1)
|
||||
elif globals.current_window == 1:
|
||||
scroll_messages(1)
|
||||
elif globals.current_window == 2:
|
||||
scroll_nodes(1)
|
||||
|
||||
elif char == curses.KEY_HOME:
|
||||
if globals.current_window == 0:
|
||||
select_channel(0)
|
||||
elif globals.current_window == 1:
|
||||
globals.selected_message = 0
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(0)
|
||||
|
||||
elif char == curses.KEY_END:
|
||||
if globals.current_window == 0:
|
||||
select_channel(len(globals.channel_list) - 1)
|
||||
elif globals.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(len(globals.node_list) - 1)
|
||||
|
||||
elif char == curses.KEY_PPAGE:
|
||||
if globals.current_window == 0:
|
||||
select_channel(globals.selected_channel - (channel_win.getmaxyx()[0] - 2)) # select_channel will bounds check for us
|
||||
elif globals.current_window == 1:
|
||||
globals.selected_message = max(globals.selected_message - get_msg_window_lines(), 0)
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(globals.selected_node - (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||
|
||||
elif char == curses.KEY_NPAGE:
|
||||
if globals.current_window == 0:
|
||||
select_channel(globals.selected_channel + (channel_win.getmaxyx()[0] - 2)) # select_channel will bounds check for us
|
||||
elif globals.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = min(globals.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines())
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(globals.selected_node + (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||
|
||||
elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT:
|
||||
delta = -1 if char == curses.KEY_LEFT else 1
|
||||
|
||||
old_window = globals.current_window
|
||||
globals.current_window = (globals.current_window + delta) % 3
|
||||
|
||||
if old_window == 0:
|
||||
channel_win.attrset(get_color("window_frame"))
|
||||
channel_win.box()
|
||||
channel_win.refresh()
|
||||
highlight_line(False, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
if old_window == 1:
|
||||
messages_win.attrset(get_color("window_frame"))
|
||||
messages_win.box()
|
||||
messages_win.refresh()
|
||||
refresh_pad(1)
|
||||
elif old_window == 2:
|
||||
draw_function_win()
|
||||
nodes_win.attrset(get_color("window_frame"))
|
||||
nodes_win.box()
|
||||
nodes_win.refresh()
|
||||
highlight_line(False, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
if globals.current_window == 0:
|
||||
channel_win.attrset(get_color("window_frame_selected"))
|
||||
channel_win.box()
|
||||
channel_win.attrset(get_color("window_frame"))
|
||||
channel_win.refresh()
|
||||
highlight_line(True, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
elif globals.current_window == 1:
|
||||
messages_win.attrset(get_color("window_frame_selected"))
|
||||
messages_win.box()
|
||||
messages_win.attrset(get_color("window_frame"))
|
||||
messages_win.refresh()
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
draw_function_win()
|
||||
nodes_win.attrset(get_color("window_frame_selected"))
|
||||
nodes_win.box()
|
||||
nodes_win.attrset(get_color("window_frame"))
|
||||
nodes_win.refresh()
|
||||
highlight_line(True, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
# Check for Esc
|
||||
elif char == chr(27):
|
||||
break
|
||||
|
||||
# Check for Ctrl + t
|
||||
elif char == chr(20):
|
||||
send_traceroute()
|
||||
curses.curs_set(0) # Hide cursor
|
||||
contact.ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.")
|
||||
curses.curs_set(1) # Show cursor again
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
if globals.current_window == 2:
|
||||
node_list = globals.node_list
|
||||
if node_list[globals.selected_node] not in globals.channel_list:
|
||||
globals.channel_list.append(node_list[globals.selected_node])
|
||||
if(node_list[globals.selected_node] not in globals.all_messages):
|
||||
globals.all_messages[node_list[globals.selected_node]] = []
|
||||
|
||||
|
||||
globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node])
|
||||
|
||||
if(is_chat_archived(globals.channel_list[globals.selected_channel])):
|
||||
update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=False)
|
||||
|
||||
globals.selected_node = 0
|
||||
globals.current_window = 0
|
||||
|
||||
draw_node_list()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
|
||||
elif len(input_text) > 0:
|
||||
# Enter key pressed, send user input as message
|
||||
send_message(input_text, channel=globals.selected_channel)
|
||||
draw_messages_window(True)
|
||||
|
||||
# Clear entry window and reset input text
|
||||
input_text = ""
|
||||
entry_win.erase()
|
||||
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
||||
if input_text:
|
||||
input_text = input_text[:-1]
|
||||
y, x = entry_win.getyx()
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.addch(' ') #
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.refresh()
|
||||
|
||||
elif char == "`": # ` Launch the settings interface
|
||||
curses.curs_set(0)
|
||||
settings_menu(stdscr, globals.interface)
|
||||
curses.curs_set(1)
|
||||
refresh_node_list()
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char == chr(16):
|
||||
# Display packet log
|
||||
if globals.display_log is False:
|
||||
globals.display_log = True
|
||||
draw_messages_window(True)
|
||||
else:
|
||||
globals.display_log = False
|
||||
packetlog_win.erase()
|
||||
draw_messages_window(True)
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
# ^D
|
||||
elif char == chr(4):
|
||||
if(globals.current_window == 0):
|
||||
if(isinstance(globals.channel_list[globals.selected_channel], int)):
|
||||
update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=True)
|
||||
|
||||
# Shift notifications up to account for deleted item
|
||||
for i in range(len(globals.notifications)):
|
||||
if globals.notifications[i] > globals.selected_channel:
|
||||
globals.notifications[i] -= 1
|
||||
|
||||
del globals.channel_list[globals.selected_channel]
|
||||
globals.selected_channel = min(globals.selected_channel, len(globals.channel_list) - 1)
|
||||
select_channel(globals.selected_channel)
|
||||
draw_channel_list()
|
||||
draw_messages_window()
|
||||
|
||||
if(globals.current_window == 2):
|
||||
curses.curs_set(0)
|
||||
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from nodedb?", "No", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
globals.interface.localNode.removeNode(globals.node_list[globals.selected_node])
|
||||
|
||||
# Directly modifying the interface from client code - good? Bad? If it's stupid but it works, it's not supid?
|
||||
del(globals.interface.nodesByNum[globals.node_list[globals.selected_node]])
|
||||
|
||||
# Convert to "!hex" representation that interface.nodes uses
|
||||
hexid = f"!{hex(globals.node_list[globals.selected_node])[2:]}"
|
||||
del(globals.interface.nodes[hexid])
|
||||
|
||||
globals.node_list.pop(globals.selected_node)
|
||||
|
||||
draw_messages_window()
|
||||
draw_node_list()
|
||||
else:
|
||||
draw_messages_window()
|
||||
curses.curs_set(1)
|
||||
continue
|
||||
|
||||
# ^/
|
||||
elif char == chr(31):
|
||||
if(globals.current_window == 2 or globals.current_window == 0):
|
||||
search(globals.current_window)
|
||||
|
||||
# ^F
|
||||
elif char == chr(6):
|
||||
if globals.current_window == 2:
|
||||
selectedNode = globals.interface.nodesByNum[globals.node_list[globals.selected_node]]
|
||||
|
||||
curses.curs_set(0)
|
||||
|
||||
if 'isFavorite' not in selectedNode or selectedNode['isFavorite'] == False:
|
||||
confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Favorite?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
globals.interface.localNode.setFavorite(globals.node_list[globals.selected_node])
|
||||
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
|
||||
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = True
|
||||
|
||||
refresh_node_list()
|
||||
|
||||
else:
|
||||
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Favorites?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
globals.interface.localNode.removeFavorite(globals.node_list[globals.selected_node])
|
||||
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
|
||||
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = False
|
||||
|
||||
refresh_node_list()
|
||||
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char == chr(7):
|
||||
if globals.current_window == 2:
|
||||
selectedNode = globals.interface.nodesByNum[globals.node_list[globals.selected_node]]
|
||||
|
||||
curses.curs_set(0)
|
||||
|
||||
if 'isIgnored' not in selectedNode or selectedNode['isIgnored'] == False:
|
||||
confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Ignored?", "No", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
globals.interface.localNode.setIgnored(globals.node_list[globals.selected_node])
|
||||
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isIgnored'] = True
|
||||
else:
|
||||
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Ignored?", "No", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
globals.interface.localNode.removeIgnored(globals.node_list[globals.selected_node])
|
||||
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isIgnored'] = False
|
||||
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if(isinstance(char, str)):
|
||||
input_text += char
|
||||
else:
|
||||
input_text += chr(char)
|
||||
|
||||
|
||||
|
||||
def draw_channel_list():
|
||||
channel_pad.erase()
|
||||
win_height, win_width = channel_win.getmaxyx()
|
||||
start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders
|
||||
|
||||
channel_pad.resize(len(globals.all_messages), channel_win.getmaxyx()[1])
|
||||
|
||||
idx = 0
|
||||
for channel in globals.channel_list:
|
||||
# Convert node number to long name if it's an integer
|
||||
if isinstance(channel, int):
|
||||
if is_chat_archived(channel):
|
||||
continue
|
||||
channel_name = get_name_from_database(channel, type='long')
|
||||
if channel_name is None:
|
||||
continue
|
||||
channel = channel_name
|
||||
|
||||
# Determine whether to add the notification
|
||||
notification = " " + config.notification_symbol if idx in globals.notifications else ""
|
||||
|
||||
# Truncate the channel name if it's too long to fit in the window
|
||||
truncated_channel = ((channel[:win_width - 5] + '-' if len(channel) > win_width - 5 else channel) + notification).ljust(win_width - 3)
|
||||
|
||||
color = get_color("channel_list")
|
||||
if idx == globals.selected_channel:
|
||||
if globals.current_window == 0:
|
||||
color = get_color("channel_list", reverse=True)
|
||||
remove_notification(globals.selected_channel)
|
||||
else:
|
||||
color = get_color("channel_selected")
|
||||
channel_pad.addstr(idx, 1, truncated_channel, color)
|
||||
idx += 1
|
||||
|
||||
channel_win.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame"))
|
||||
channel_win.box()
|
||||
channel_win.attrset((get_color("window_frame")))
|
||||
channel_win.refresh()
|
||||
|
||||
refresh_pad(0)
|
||||
|
||||
def draw_messages_window(scroll_to_bottom = False):
|
||||
"""Update the messages window based on the selected channel and scroll position."""
|
||||
messages_pad.erase()
|
||||
|
||||
channel = globals.channel_list[globals.selected_channel]
|
||||
|
||||
if channel in globals.all_messages:
|
||||
messages = globals.all_messages[channel]
|
||||
|
||||
msg_line_count = 0
|
||||
|
||||
row = 0
|
||||
for (prefix, message) in messages:
|
||||
full_message = f"{prefix}{message}"
|
||||
wrapped_lines = textwrap.wrap(full_message, messages_win.getmaxyx()[1] - 2)
|
||||
msg_line_count += len(wrapped_lines)
|
||||
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
||||
|
||||
for line in wrapped_lines:
|
||||
if prefix.startswith("--"):
|
||||
color = get_color("timestamps")
|
||||
elif prefix.startswith(config.sent_message_prefix):
|
||||
color = get_color("tx_messages")
|
||||
else:
|
||||
color = get_color("rx_messages")
|
||||
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
row += 1
|
||||
|
||||
messages_win.attrset(get_color("window_frame_selected") if globals.current_window == 1 else get_color("window_frame"))
|
||||
messages_win.box()
|
||||
messages_win.attrset(get_color("window_frame"))
|
||||
messages_win.refresh()
|
||||
|
||||
if(scroll_to_bottom):
|
||||
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
|
||||
else:
|
||||
globals.selected_message = max(min(globals.selected_message, msg_line_count - get_msg_window_lines()), 0)
|
||||
|
||||
refresh_pad(1)
|
||||
|
||||
draw_packetlog_win()
|
||||
|
||||
def draw_node_list():
|
||||
global nodes_pad
|
||||
|
||||
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
|
||||
# if nodes_pad is None:
|
||||
# nodes_pad = curses.newpad(1, 1)
|
||||
nodes_pad = curses.newpad(1, 1)
|
||||
|
||||
|
||||
try:
|
||||
nodes_pad.erase()
|
||||
box_width = nodes_win.getmaxyx()[1]
|
||||
nodes_pad.resize(len(globals.node_list) + 1, box_width)
|
||||
except Exception as e:
|
||||
logging.error(f"Error Drawing Nodes List: {e}")
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
|
||||
for i, node_num in enumerate(globals.node_list):
|
||||
node = globals.interface.nodesByNum[node_num]
|
||||
secure = 'user' in node and 'publicKey' in node['user'] and node['user']['publicKey']
|
||||
node_str = f"{'🔐' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[:box_width - 2]
|
||||
color = "node_list"
|
||||
if 'isFavorite' in node and node['isFavorite']:
|
||||
color = "node_favorite"
|
||||
if 'isIgnored' in node and node['isIgnored']:
|
||||
color = "node_ignored"
|
||||
nodes_pad.addstr(i, 1, node_str, get_color(color, reverse=globals.selected_node == i and globals.current_window == 2))
|
||||
|
||||
nodes_win.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame"))
|
||||
nodes_win.box()
|
||||
nodes_win.attrset(get_color("window_frame"))
|
||||
nodes_win.refresh()
|
||||
|
||||
refresh_pad(2)
|
||||
|
||||
# Restore cursor to input field
|
||||
entry_win.keypad(True)
|
||||
curses.curs_set(1)
|
||||
entry_win.refresh()
|
||||
|
||||
def select_channel(idx):
|
||||
old_selected_channel = globals.selected_channel
|
||||
globals.selected_channel = max(0, min(idx, len(globals.channel_list) - 1))
|
||||
draw_messages_window(True)
|
||||
|
||||
# For now just re-draw channel list when clearing notifications, we can probably make this more efficient
|
||||
if globals.selected_channel in globals.notifications:
|
||||
remove_notification(globals.selected_channel)
|
||||
draw_channel_list()
|
||||
return
|
||||
highlight_line(False, 0, old_selected_channel)
|
||||
highlight_line(True, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
|
||||
def scroll_channels(direction):
|
||||
new_selected_channel = globals.selected_channel + direction
|
||||
|
||||
if new_selected_channel < 0:
|
||||
new_selected_channel = len(globals.channel_list) - 1
|
||||
elif new_selected_channel >= len(globals.channel_list):
|
||||
new_selected_channel = 0
|
||||
|
||||
select_channel(new_selected_channel)
|
||||
|
||||
def scroll_messages(direction):
|
||||
globals.selected_message += direction
|
||||
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = max(0, min(globals.selected_message, msg_line_count - get_msg_window_lines()))
|
||||
|
||||
refresh_pad(1)
|
||||
|
||||
def select_node(idx):
|
||||
old_selected_node = globals.selected_node
|
||||
globals.selected_node = max(0, min(idx, len(globals.node_list) - 1))
|
||||
|
||||
highlight_line(False, 2, old_selected_node)
|
||||
highlight_line(True, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
draw_function_win()
|
||||
|
||||
def scroll_nodes(direction):
|
||||
new_selected_node = globals.selected_node + direction
|
||||
|
||||
if new_selected_node < 0:
|
||||
new_selected_node = len(globals.node_list) - 1
|
||||
elif new_selected_node >= len(globals.node_list):
|
||||
new_selected_node = 0
|
||||
|
||||
select_node(new_selected_node)
|
||||
|
||||
def draw_packetlog_win():
|
||||
|
||||
columns = [10,10,15,30]
|
||||
span = 0
|
||||
|
||||
if globals.display_log:
|
||||
packetlog_win.erase()
|
||||
height, width = packetlog_win.getmaxyx()
|
||||
|
||||
for column in columns[:-1]:
|
||||
span += column
|
||||
|
||||
# Add headers
|
||||
headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}"
|
||||
packetlog_win.addstr(1, 1, headers[:width - 2],get_color("log_header", underline=True)) # Truncate headers if they exceed window width
|
||||
|
||||
for i, packet in enumerate(reversed(globals.packet_buffer)):
|
||||
if i >= height - 3: # Skip if exceeds the window height
|
||||
break
|
||||
|
||||
# Format each field
|
||||
from_id = get_name_from_database(packet['from'], 'short').ljust(columns[0])
|
||||
to_id = (
|
||||
"BROADCAST".ljust(columns[1]) if str(packet['to']) == "4294967295"
|
||||
else get_name_from_database(packet['to'], 'short').ljust(columns[1])
|
||||
)
|
||||
if 'decoded' in packet:
|
||||
port = packet['decoded']['portnum'].ljust(columns[2])
|
||||
payload = (packet['decoded']['payload']).ljust(columns[3])
|
||||
else:
|
||||
port = "NO KEY".ljust(columns[2])
|
||||
payload = "NO KEY".ljust(columns[3])
|
||||
|
||||
# Combine and truncate if necessary
|
||||
logString = f"{from_id} {to_id} {port} {payload}"
|
||||
logString = logString[:width - 3]
|
||||
|
||||
# Add to the window
|
||||
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
|
||||
|
||||
packetlog_win.attrset(get_color("window_frame"))
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
|
||||
# Restore cursor to input field
|
||||
entry_win.keypad(True)
|
||||
curses.curs_set(1)
|
||||
entry_win.refresh()
|
||||
|
||||
def search(win):
|
||||
start_idx = globals.selected_node
|
||||
select_func = select_node
|
||||
|
||||
if win == 0:
|
||||
start_idx = globals.selected_channel
|
||||
select_func = select_channel
|
||||
|
||||
search_text = ""
|
||||
entry_win.erase()
|
||||
|
||||
while True:
|
||||
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
|
||||
char = entry_win.get_wch()
|
||||
|
||||
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
break
|
||||
elif char == "\t":
|
||||
start_idx = globals.selected_node + 1 if win == 2 else globals.selected_channel + 1
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
||||
if search_text:
|
||||
search_text = search_text[:-1]
|
||||
y, x = entry_win.getyx()
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.addch(' ') #
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.erase()
|
||||
entry_win.refresh()
|
||||
elif isinstance(char, str):
|
||||
search_text += char
|
||||
|
||||
search_text_caseless = search_text.casefold()
|
||||
|
||||
l = globals.node_list if win == 2 else globals.channel_list
|
||||
for i, n in enumerate(l[start_idx:] + l[:start_idx]):
|
||||
if isinstance(n, int) and search_text_caseless in get_name_from_database(n, 'long').casefold() \
|
||||
or isinstance(n, int) and search_text_caseless in get_name_from_database(n, 'short').casefold() \
|
||||
or search_text_caseless in str(n).casefold():
|
||||
select_func((i + start_idx) % len(l))
|
||||
break
|
||||
|
||||
entry_win.erase()
|
||||
|
||||
def draw_node_details():
|
||||
node = None
|
||||
try:
|
||||
node = globals.interface.nodesByNum[globals.node_list[globals.selected_node]]
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
function_win.erase()
|
||||
function_win.box()
|
||||
|
||||
nodestr = ""
|
||||
width = function_win.getmaxyx()[1]
|
||||
|
||||
node_details_list = [f"{node['user']['longName']} "
|
||||
if 'user' in node and 'longName' in node['user'] else "",
|
||||
f"({node['user']['shortName']})"
|
||||
if 'user' in node and 'shortName' in node['user'] else "",
|
||||
f" | {node['user']['hwModel']}"
|
||||
if 'user' in node and 'hwModel' in node['user'] else "",
|
||||
f" | {node['user']['role']}"
|
||||
if 'user' in node and 'role' in node['user'] else ""]
|
||||
|
||||
if globals.node_list[globals.selected_node] == globals.myNodeNum:
|
||||
node_details_list.extend([f" | Bat: {node['deviceMetrics']['batteryLevel']}% ({node['deviceMetrics']['voltage']}v)"
|
||||
if 'deviceMetrics' in node
|
||||
and 'batteryLevel' in node['deviceMetrics']
|
||||
and 'voltage' in node['deviceMetrics'] else "",
|
||||
f" | Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}" if 'deviceMetrics' in node
|
||||
and 'uptimeSeconds' in node['deviceMetrics'] else "",
|
||||
f" | ChUtil: {node['deviceMetrics']['channelUtilization']:.2f}%" if 'deviceMetrics' in node
|
||||
and 'channelUtilization' in node['deviceMetrics'] else "",
|
||||
f" | AirUtilTX: {node['deviceMetrics']['airUtilTx']:.2f}%" if 'deviceMetrics' in node
|
||||
and 'airUtilTx' in node['deviceMetrics'] else "",
|
||||
])
|
||||
else:
|
||||
node_details_list.extend([f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "",
|
||||
f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "",
|
||||
f" | SNR: {node['snr']}dB"
|
||||
if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0)
|
||||
else "",
|
||||
])
|
||||
|
||||
for s in node_details_list:
|
||||
if len(nodestr) + len(s) < width - 2:
|
||||
nodestr = nodestr + s
|
||||
|
||||
draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
|
||||
|
||||
def draw_help():
|
||||
cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat", " ^f = Favorite", " ^g = Ignore"]
|
||||
function_str = ""
|
||||
for s in cmds:
|
||||
if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2):
|
||||
function_str += s
|
||||
|
||||
draw_centered_text_field(function_win, function_str, 0, get_color("commands"))
|
||||
|
||||
def draw_function_win():
|
||||
if(globals.current_window == 2):
|
||||
draw_node_details()
|
||||
else:
|
||||
draw_help()
|
||||
|
||||
def get_msg_window_lines():
|
||||
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if globals.display_log else 0
|
||||
return messages_win.getmaxyx()[0] - 2 - packetlog_height
|
||||
|
||||
def refresh_pad(window):
|
||||
# global messages_pad, nodes_pad, channel_pad
|
||||
|
||||
win_height = channel_win.getmaxyx()[0]
|
||||
|
||||
if(window == 1):
|
||||
pad = messages_pad
|
||||
box = messages_win
|
||||
lines = get_msg_window_lines()
|
||||
selected_item = globals.selected_message
|
||||
start_index = globals.selected_message
|
||||
|
||||
if globals.display_log:
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
|
||||
elif(window == 2):
|
||||
pad = nodes_pad
|
||||
box = nodes_win
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
selected_item = globals.selected_node
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
else:
|
||||
pad = channel_pad
|
||||
box = channel_win
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
selected_item = globals.selected_channel
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
pad.refresh(start_index, 0,
|
||||
box.getbegyx()[0] + 1, box.getbegyx()[1] + 1,
|
||||
box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2)
|
||||
|
||||
def highlight_line(highlight, window, line):
|
||||
pad = nodes_pad
|
||||
|
||||
color = get_color("node_list")
|
||||
select_len = nodes_win.getmaxyx()[1] - 2
|
||||
|
||||
if window == 2:
|
||||
node_num = globals.node_list[line]
|
||||
node = globals.interface.nodesByNum[node_num]
|
||||
if 'isFavorite' in node and node['isFavorite']:
|
||||
color = get_color("node_favorite")
|
||||
if 'isIgnored' in node and node['isIgnored']:
|
||||
color = get_color("node_ignored")
|
||||
|
||||
if(window == 0):
|
||||
pad = channel_pad
|
||||
color = get_color("channel_selected" if (line == globals.selected_channel and highlight == False) else "channel_list")
|
||||
select_len = channel_win.getmaxyx()[1] - 2
|
||||
|
||||
pad.chgat(line, 1, select_len, color | curses.A_REVERSE if highlight else color)
|
||||
|
||||
def add_notification(channel_number):
|
||||
if channel_number not in globals.notifications:
|
||||
globals.notifications.append(channel_number)
|
||||
|
||||
def remove_notification(channel_number):
|
||||
if channel_number in globals.notifications:
|
||||
globals.notifications.remove(channel_number)
|
||||
|
||||
def draw_text_field(win, text, color):
|
||||
win.border()
|
||||
win.addstr(1, 1, text, color)
|
||||
|
||||
def draw_centered_text_field(win, text, y_offset, color):
|
||||
height, width = win.getmaxyx()
|
||||
x = (width - len(text)) // 2
|
||||
y = (height // 2) + y_offset
|
||||
win.addstr(y, x, text, color)
|
||||
win.refresh()
|
||||
|
||||
def draw_debug(value):
|
||||
function_win.addstr(1, 1, f"debug: {value} ")
|
||||
function_win.refresh()
|
||||
670
contact/ui/control_ui.py
Normal file
670
contact/ui/control_ui.py
Normal file
@@ -0,0 +1,670 @@
|
||||
import base64
|
||||
import curses
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from contact.utilities.save_to_radio import save_changes
|
||||
from contact.utilities.config_io import config_export, config_import
|
||||
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.utilities.control_utils import parse_ini_file, transform_menu_path
|
||||
from contact.ui.user_config import json_editor
|
||||
from contact.ui.ui_state import MenuState
|
||||
|
||||
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"]
|
||||
|
||||
# Get the parent directory of the script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||
|
||||
# Paths
|
||||
locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
|
||||
translation_file = os.path.join(parent_dir, "localisations", "en.ini")
|
||||
|
||||
config_folder = os.path.join(locals_dir, "node-configs")
|
||||
|
||||
# Load translations
|
||||
field_mapping, help_text = parse_ini_file(translation_file)
|
||||
|
||||
|
||||
def display_menu(state):
|
||||
|
||||
min_help_window_height = 6
|
||||
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)
|
||||
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 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, 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(state.current_menu) + 1, width - 8)
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
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(state.menu_path)
|
||||
|
||||
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])
|
||||
display_name = field_mapping.get(full_key, option)
|
||||
|
||||
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 == 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 state.show_save_option:
|
||||
save_position = menu_height - 2
|
||||
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, state)
|
||||
|
||||
menu_win.refresh()
|
||||
menu_pad.refresh(
|
||||
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 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, state)
|
||||
|
||||
return menu_win, menu_pad
|
||||
|
||||
|
||||
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():
|
||||
help_win = None # Initialize if it does not exist
|
||||
|
||||
selected_option = list(state.current_menu.keys())[state.selected_index] if state.current_menu else None
|
||||
help_y = menu_start_y + menu_height
|
||||
|
||||
help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x)
|
||||
|
||||
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)
|
||||
|
||||
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
|
||||
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
|
||||
|
||||
# Ensure help window does not exceed screen size
|
||||
if help_y + help_height > curses.LINES:
|
||||
help_y = curses.LINES - help_height
|
||||
|
||||
# Create or update the help window
|
||||
if help_win is None:
|
||||
help_win = curses.newwin(help_height, width, help_y, help_x)
|
||||
else:
|
||||
help_win.erase()
|
||||
help_win.refresh()
|
||||
help_win.resize(help_height, width)
|
||||
help_win.mvwin(help_y, help_x)
|
||||
|
||||
help_win.bkgd(get_color("background"))
|
||||
help_win.attrset(get_color("window_frame"))
|
||||
help_win.border()
|
||||
|
||||
for idx, line_segments in enumerate(wrapped_help):
|
||||
x_pos = 2 # Start after border
|
||||
for text, color, bold, underline in line_segments:
|
||||
try:
|
||||
attr = get_color(color, bold=bold, underline=underline)
|
||||
help_win.addstr(1 + idx, x_pos, text, attr)
|
||||
x_pos += len(text)
|
||||
except curses.error:
|
||||
pass # Prevent crashes
|
||||
|
||||
help_win.refresh()
|
||||
return help_win
|
||||
|
||||
|
||||
def get_wrapped_help_text(help_text, 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)
|
||||
state.current_menu = menu["Main Menu"]
|
||||
state.menu_path = ["Main Menu"]
|
||||
|
||||
|
||||
modified_settings = {}
|
||||
|
||||
need_redraw = True
|
||||
state.show_save_option = False
|
||||
|
||||
while True:
|
||||
if(need_redraw):
|
||||
options = list(state.current_menu.keys())
|
||||
|
||||
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(state)
|
||||
|
||||
need_redraw = False
|
||||
|
||||
# Capture user input
|
||||
key = menu_win.getch()
|
||||
|
||||
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 = 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 = 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:
|
||||
need_redraw = True
|
||||
curses.update_lines_cols()
|
||||
|
||||
menu_win.erase()
|
||||
help_win.erase()
|
||||
|
||||
menu_win.refresh()
|
||||
help_win.refresh()
|
||||
|
||||
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'):
|
||||
need_redraw = True
|
||||
state.start_index.append(0)
|
||||
menu_win.erase()
|
||||
help_win.erase()
|
||||
|
||||
# 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()
|
||||
help_win.refresh()
|
||||
|
||||
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(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[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")
|
||||
if not filename:
|
||||
logging.info("Export aborted: No filename provided.")
|
||||
state.start_index.pop()
|
||||
continue # Go back to the menu
|
||||
if not filename.lower().endswith(".yaml"):
|
||||
filename += ".yaml"
|
||||
|
||||
try:
|
||||
config_text = config_export(interface)
|
||||
yaml_file_path = os.path.join(config_folder, filename)
|
||||
|
||||
if os.path.exists(yaml_file_path):
|
||||
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
|
||||
if overwrite == "No":
|
||||
logging.info("Export cancelled: User chose not to overwrite.")
|
||||
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(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}")
|
||||
except OSError as e:
|
||||
logging.error(f"OS error while saving config: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error: {e}")
|
||||
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(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(stdscr, "", " No config files found. Export a config first.")
|
||||
continue
|
||||
|
||||
filename = get_list_input("Choose a config file", None, file_list)
|
||||
if filename:
|
||||
file_path = os.path.join(config_folder, filename)
|
||||
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
|
||||
if overwrite == "Yes":
|
||||
config_import(interface, file_path)
|
||||
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}")
|
||||
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")
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Reboot":
|
||||
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")
|
||||
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"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.resetNodeDb()
|
||||
logging.info(f"Node DB Reset Requested by menu")
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Shutdown":
|
||||
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.shutdown()
|
||||
logging.info(f"Node Shutdown Requested by menu")
|
||||
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"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.factoryReset()
|
||||
logging.info(f"Factory Reset Requested by menu")
|
||||
state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "App Settings":
|
||||
menu_win.clear()
|
||||
menu_win.refresh()
|
||||
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
|
||||
# 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(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}")
|
||||
new_value = current_value if new_value is None else 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"])
|
||||
new_value = new_value == "True"
|
||||
state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
for option, (field, value) in state.current_menu.items():
|
||||
modified_settings[option] = value
|
||||
|
||||
state.start_index.pop()
|
||||
|
||||
elif selected_option in ['latitude', 'longitude', 'altitude']:
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
for option in ['latitude', 'longitude', 'altitude']:
|
||||
if option in state.current_menu:
|
||||
modified_settings[option] = state.current_menu[option][1]
|
||||
|
||||
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]
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 8: # Handle boolean type
|
||||
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
|
||||
if new_value == "Not Set":
|
||||
pass # Leave it as-is
|
||||
else:
|
||||
new_value = new_value == "True" or new_value is True
|
||||
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(", ")
|
||||
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)
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 7: # Field type 7 corresponds to FIXED32
|
||||
new_value = get_fixed32_input(current_value)
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 13: # Field type 13 corresponds to UINT32
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else int(new_value)
|
||||
state.start_index.pop()
|
||||
|
||||
elif field.type == 2: # Field type 13 corresponds to INT64
|
||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
||||
new_value = current_value if new_value is None else float(new_value)
|
||||
state.start_index.pop()
|
||||
|
||||
else: # Handle other field types
|
||||
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
|
||||
state.start_index.pop()
|
||||
|
||||
for key in state.menu_path[3:]: # Skip "Main Menu"
|
||||
modified_settings = modified_settings.setdefault(key, {})
|
||||
|
||||
# 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
|
||||
|
||||
state.current_menu[selected_option] = (field, new_value)
|
||||
else:
|
||||
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:
|
||||
need_redraw = True
|
||||
|
||||
menu_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, state.current_menu, selected_index, transform_menu_path(state.menu_path))
|
||||
|
||||
menu_win.refresh()
|
||||
help_win.refresh()
|
||||
|
||||
if len(state.menu_path) < 2:
|
||||
modified_settings.clear()
|
||||
|
||||
# Navigate back to the previous menu
|
||||
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 set_region(interface):
|
||||
node = interface.getNode('^local')
|
||||
device_config = node.localConfig
|
||||
lora_descriptor = device_config.lora.DESCRIPTOR
|
||||
|
||||
# Get the enum mapping of region names to their numerical values
|
||||
region_enum = lora_descriptor.fields_by_name["region"].enum_type
|
||||
region_name_to_number = {v.name: v.number for v in region_enum.values}
|
||||
|
||||
regions = list(region_name_to_number.keys())
|
||||
|
||||
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")
|
||||
@@ -2,6 +2,15 @@ import logging
|
||||
import json
|
||||
import os
|
||||
|
||||
# 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
|
||||
json_file_path = os.path.join(parent_dir, "config.json")
|
||||
log_file_path = os.path.join(parent_dir, "client.log")
|
||||
db_file_path = os.path.join(parent_dir, "client.db")
|
||||
|
||||
def format_json_single_line_arrays(data, indent=4):
|
||||
"""
|
||||
Formats JSON with arrays on a single line while keeping other elements properly indented.
|
||||
@@ -34,9 +43,6 @@ def update_dict(default, actual):
|
||||
return updated
|
||||
|
||||
def initialize_config():
|
||||
app_directory = os.path.dirname(os.path.abspath(__file__))
|
||||
json_file_path = os.path.join(app_directory, "config.json")
|
||||
|
||||
COLOR_CONFIG_DARK = {
|
||||
"default": ["white", "black"],
|
||||
"background": [" ", "black"],
|
||||
@@ -57,9 +63,12 @@ def initialize_config():
|
||||
"settings_default": ["white", "black"],
|
||||
"settings_sensitive": ["red", "black"],
|
||||
"settings_save": ["green", "black"],
|
||||
"settings_breadcrumbs": ["white", "black"]
|
||||
"settings_breadcrumbs": ["white", "black"],
|
||||
"settings_warning": ["red", "black"],
|
||||
"settings_note": ["green", "black"],
|
||||
"node_favorite": ["green", "black"],
|
||||
"node_ignored": ["red", "black"]
|
||||
}
|
||||
|
||||
COLOR_CONFIG_LIGHT = {
|
||||
"default": ["black", "white"],
|
||||
"background": [" ", "white"],
|
||||
@@ -80,7 +89,11 @@ def initialize_config():
|
||||
"settings_default": ["black", "white"],
|
||||
"settings_sensitive": ["red", "white"],
|
||||
"settings_save": ["green", "white"],
|
||||
"settings_breadcrumbs": ["black", "white"]
|
||||
"settings_breadcrumbs": ["black", "white"],
|
||||
"settings_warning": ["red", "white"],
|
||||
"settings_note": ["green", "white"],
|
||||
"node_favorite": ["green", "white"],
|
||||
"node_ignored": ["red", "white"]
|
||||
}
|
||||
COLOR_CONFIG_GREEN = {
|
||||
"default": ["green", "black"],
|
||||
@@ -102,12 +115,17 @@ def initialize_config():
|
||||
"settings_default": ["green", "black"],
|
||||
"settings_sensitive": ["green", "black"],
|
||||
"settings_save": ["green", "black"],
|
||||
"settings_breadcrumbs": ["green", "black"]
|
||||
"settings_breadcrumbs": ["green", "black"],
|
||||
"settings_save": ["green", "black"],
|
||||
"settings_breadcrumbs": ["green", "black"],
|
||||
"settings_warning": ["green", "black"],
|
||||
"settings_note": ["green", "black"],
|
||||
"node_favorite": ["cyan", "white"],
|
||||
"node_ignored": ["red", "white"]
|
||||
}
|
||||
|
||||
default_config_variables = {
|
||||
"db_file_path": os.path.join(app_directory, "client.db"),
|
||||
"log_file_path": os.path.join(app_directory, "client.log"),
|
||||
"db_file_path": db_file_path,
|
||||
"log_file_path": log_file_path,
|
||||
"message_prefix": ">>",
|
||||
"sent_message_prefix": ">> Sent",
|
||||
"notification_symbol": "*",
|
||||
@@ -115,6 +133,7 @@ def initialize_config():
|
||||
"ack_str": "[✓]",
|
||||
"nak_str": "[x]",
|
||||
"ack_unknown_str": "[…]",
|
||||
"node_sort": "lastHeard",
|
||||
"theme": "dark",
|
||||
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
|
||||
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
|
||||
@@ -148,6 +167,7 @@ def assign_config_variables(loaded_config):
|
||||
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 theme, COLOR_CONFIG
|
||||
global node_sort
|
||||
|
||||
db_file_path = loaded_config["db_file_path"]
|
||||
log_file_path = loaded_config["log_file_path"]
|
||||
@@ -165,6 +185,7 @@ def assign_config_variables(loaded_config):
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
import curses
|
||||
from ui.colors import get_color
|
||||
from contact.ui.colors import get_color
|
||||
|
||||
def dialog(stdscr, title, message):
|
||||
height, width = stdscr.getmaxyx()
|
||||
@@ -1,12 +1,21 @@
|
||||
from collections import OrderedDict
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2
|
||||
import logging, traceback
|
||||
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):
|
||||
"""Encode byte values to base64 string."""
|
||||
if isinstance(value, bytes):
|
||||
return base64.b64encode(value).decode('utf-8')
|
||||
return value
|
||||
|
||||
def extract_fields(message_instance, current_config=None):
|
||||
if isinstance(current_config, dict): # Handle dictionaries
|
||||
return {key: (None, current_config.get(key, "Not Set")) for key in current_config}
|
||||
return {key: (None, encode_if_bytes(current_config.get(key, "Not Set"))) for key in current_config}
|
||||
|
||||
if not hasattr(message_instance, "DESCRIPTOR"):
|
||||
return {}
|
||||
@@ -14,11 +23,10 @@ def extract_fields(message_instance, current_config=None):
|
||||
menu = {}
|
||||
fields = message_instance.DESCRIPTOR.fields
|
||||
for field in fields:
|
||||
if field.name in {"sessionkey", "channel_num", "id", "ignore_incoming"}: # Skip certain fields
|
||||
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
|
||||
|
||||
|
||||
|
||||
if field.message_type: # Nested message
|
||||
nested_instance = getattr(message_instance, field.name)
|
||||
nested_config = getattr(current_config, field.name, None) if current_config else None
|
||||
@@ -36,29 +44,23 @@ def extract_fields(message_instance, current_config=None):
|
||||
menu[field.name] = (field, current_value) # Non-integer values
|
||||
else: # Handle other field types
|
||||
current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set"
|
||||
menu[field.name] = (field, current_value)
|
||||
|
||||
menu[field.name] = (field, encode_if_bytes(current_value))
|
||||
return menu
|
||||
|
||||
|
||||
def generate_menu_from_protobuf(interface):
|
||||
# Function to generate the menu structure from protobuf messages
|
||||
menu_structure = {"Main Menu": {}}
|
||||
|
||||
# Add User Settings
|
||||
current_node_info = interface.getMyNodeInfo() if interface else None
|
||||
|
||||
if current_node_info:
|
||||
|
||||
current_user_config = current_node_info.get("user", None)
|
||||
if current_user_config and isinstance(current_user_config, dict):
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
else:
|
||||
logging.info("User settings not found in Node Info")
|
||||
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
|
||||
@@ -74,8 +76,6 @@ def generate_menu_from_protobuf(interface):
|
||||
current_channel = interface.localNode.getChannelByChannelIndex(i)
|
||||
if current_channel:
|
||||
channel_config = extract_fields(channel, current_channel.settings)
|
||||
# Convert 'psk' field to Base64
|
||||
channel_config["psk"] = (channel_config["psk"][0], base64.b64encode(channel_config["psk"][1]).decode('utf-8'))
|
||||
menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config
|
||||
|
||||
# Add Radio Settings
|
||||
@@ -90,10 +90,7 @@ def generate_menu_from_protobuf(interface):
|
||||
"altitude": (None, current_node_info["position"].get("altitude", 0))
|
||||
}
|
||||
|
||||
# Get existing position menu items
|
||||
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
|
||||
|
||||
# Create an ordered position menu with Lat/Lon/Alt inserted in the middle
|
||||
ordered_position_menu = OrderedDict()
|
||||
|
||||
for key, value in existing_position_menu.items():
|
||||
@@ -103,27 +100,26 @@ def generate_menu_from_protobuf(interface):
|
||||
else:
|
||||
ordered_position_menu[key] = value
|
||||
|
||||
# Update the menu with the new order
|
||||
menu_structure["Main Menu"]["Radio Settings"]["position"] = ordered_position_menu
|
||||
|
||||
|
||||
# Add Module Settings
|
||||
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"}
|
||||
|
||||
# Add additional settings options
|
||||
menu_structure["Main Menu"]["Export Config"] = None
|
||||
menu_structure["Main Menu"]["Load Config"] = None
|
||||
menu_structure["Main Menu"]["Reboot"] = None
|
||||
menu_structure["Main Menu"]["Reset Node DB"] = None
|
||||
menu_structure["Main Menu"]["Shutdown"] = None
|
||||
menu_structure["Main Menu"]["Factory Reset"] = None
|
||||
# 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
|
||||
})
|
||||
|
||||
# Add Exit option
|
||||
menu_structure["Main Menu"]["Exit"] = None
|
||||
|
||||
return menu_structure
|
||||
return menu_structure
|
||||
27
contact/ui/splash.py
Normal file
27
contact/ui/splash.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import curses
|
||||
from contact.ui.colors import get_color
|
||||
|
||||
def draw_splash(stdscr):
|
||||
curses.curs_set(0)
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.bkgd(get_color("background"))
|
||||
|
||||
height, width = stdscr.getmaxyx()
|
||||
message_1 = "/ Λ"
|
||||
message_2 = "/ / \\"
|
||||
message_3 = "P W R D"
|
||||
message_4 = "connecting..."
|
||||
|
||||
start_x = width // 2 - len(message_1) // 2
|
||||
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.attrset(get_color("window_frame"))
|
||||
stdscr.box()
|
||||
stdscr.refresh()
|
||||
curses.napms(500)
|
||||
8
contact/ui/ui_state.py
Normal file
8
contact/ui/ui_state.py
Normal file
@@ -0,0 +1,8 @@
|
||||
class MenuState:
|
||||
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
|
||||
373
contact/ui/user_config.py
Normal file
373
contact/ui/user_config.py
Normal file
@@ -0,0 +1,373 @@
|
||||
import os
|
||||
import json
|
||||
import curses
|
||||
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
|
||||
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
|
||||
width = 80
|
||||
save_option = "Save Changes"
|
||||
sensitive_settings = []
|
||||
|
||||
def edit_color_pair(key, current_value):
|
||||
|
||||
"""
|
||||
Allows the user to select a foreground and background color for a key.
|
||||
"""
|
||||
color_list = [" "] + list(COLOR_MAP.keys())
|
||||
fg_color = get_list_input(f"Select Foreground Color for {key}", current_value[0], color_list)
|
||||
bg_color = get_list_input(f"Select Background Color for {key}", current_value[1], color_list)
|
||||
|
||||
return [fg_color, bg_color]
|
||||
|
||||
def edit_value(key, current_value, state):
|
||||
|
||||
height = 10
|
||||
input_width = width - 16 # Allow space for "New Value: "
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create a centered window
|
||||
edit_win = curses.newwin(height, width, start_y, start_x)
|
||||
edit_win.bkgd(get_color("background"))
|
||||
edit_win.attrset(get_color("window_frame"))
|
||||
edit_win.border()
|
||||
|
||||
# Display instructions
|
||||
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
|
||||
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
|
||||
|
||||
wrap_width = width - 4 # Account for border and padding
|
||||
wrapped_lines = [current_value[i:i+wrap_width] for i in range(0, len(current_value), wrap_width)]
|
||||
|
||||
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
|
||||
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
|
||||
|
||||
edit_win.refresh()
|
||||
|
||||
# Handle theme selection dynamically
|
||||
if key == "theme":
|
||||
# Load theme names dynamically from the JSON
|
||||
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
|
||||
return get_list_input("Select Theme", current_value, theme_options)
|
||||
elif key == "node_sort":
|
||||
sort_options = ['lastHeard', 'name', 'hops']
|
||||
return get_list_input("Sort By", current_value, sort_options)
|
||||
|
||||
# Standard Input Mode (Scrollable)
|
||||
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
||||
curses.curs_set(1)
|
||||
|
||||
scroll_offset = 0 # Determines which part of the text is visible
|
||||
user_input = ""
|
||||
input_position = (7, 13) # Tuple for row and column
|
||||
row, col = input_position # Unpack tuple
|
||||
while True:
|
||||
visible_text = user_input[scroll_offset:scroll_offset + input_width] # Only show what fits
|
||||
edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
|
||||
edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
|
||||
edit_win.refresh()
|
||||
|
||||
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
|
||||
key = edit_win.get_wch()
|
||||
|
||||
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
|
||||
curses.curs_set(0)
|
||||
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)): # 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 # 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: # 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(state):
|
||||
"""
|
||||
Render the configuration menu with a Save button directly added to the window.
|
||||
"""
|
||||
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(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)
|
||||
num_items = len(options)
|
||||
start_y = (curses.LINES - menu_height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create the window
|
||||
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)
|
||||
|
||||
# Create the pad for scrolling
|
||||
menu_pad = curses.newpad(num_items + 1, width - 8)
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
# Display the menu path
|
||||
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 = 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]
|
||||
)
|
||||
|
||||
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 state.show_save_option:
|
||||
save_position = menu_height - 2
|
||||
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(
|
||||
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 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, state)
|
||||
|
||||
return menu_win, menu_pad, options
|
||||
|
||||
|
||||
def move_highlight(old_idx, options, menu_win, menu_pad, 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)
|
||||
|
||||
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")
|
||||
|
||||
state.show_save_option = True # Always show the Save button
|
||||
|
||||
# Ensure the file exists
|
||||
if not os.path.exists(file_path):
|
||||
with open(file_path, "w") as f:
|
||||
json.dump({}, f)
|
||||
|
||||
# Load JSON data
|
||||
with open(file_path, "r") as f:
|
||||
original_data = json.load(f)
|
||||
|
||||
data = original_data # Reference to the original data
|
||||
state.current_menu = data # Track the current level of the menu
|
||||
|
||||
# Render the menu
|
||||
menu_win, menu_pad, options = display_menu(state)
|
||||
need_redraw = True
|
||||
|
||||
while True:
|
||||
if(need_redraw):
|
||||
menu_win, menu_pad, options = display_menu(state)
|
||||
menu_win.refresh()
|
||||
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 = 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 = 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 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
|
||||
|
||||
need_redraw = True
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
|
||||
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(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(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
|
||||
new_value = edit_color_pair(selected_key, selected_data)
|
||||
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
|
||||
state.current_menu = selected_data
|
||||
state.selected_index = 0 # Reset the selected index
|
||||
|
||||
else:
|
||||
# General value editing
|
||||
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)
|
||||
stdscr.refresh()
|
||||
continue
|
||||
|
||||
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
|
||||
|
||||
need_redraw = True
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
|
||||
# state.selected_index = state.menu_index[-1]
|
||||
|
||||
# Navigate back in the menu
|
||||
if len(state.menu_path) > 2:
|
||||
state.menu_path.pop()
|
||||
state.start_index.pop()
|
||||
state.current_menu = data
|
||||
|
||||
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
|
||||
menu_win.clear()
|
||||
menu_win.refresh()
|
||||
|
||||
break
|
||||
|
||||
|
||||
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):
|
||||
from contact.ui.ui_state import MenuState
|
||||
|
||||
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, state)
|
||||
|
||||
if __name__ == "__main__":
|
||||
curses.wrapper(main)
|
||||
@@ -33,5 +33,14 @@ def setup_parser():
|
||||
default=None,
|
||||
const="any"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--settings",
|
||||
"--set",
|
||||
"--control",
|
||||
"-c",
|
||||
help="Launch directly into the settings",
|
||||
action="store_true"
|
||||
)
|
||||
|
||||
|
||||
return parser
|
||||
67
contact/utilities/control_utils.py
Normal file
67
contact/utilities/control_utils.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import re
|
||||
|
||||
|
||||
def parse_ini_file(ini_file_path):
|
||||
field_mapping = {}
|
||||
help_text = {}
|
||||
current_section = None
|
||||
|
||||
with open(ini_file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith(';') or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Handle sections like [config.device]
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
current_section = line[1:-1]
|
||||
continue
|
||||
|
||||
# Parse lines like: key, "Human-readable name", "helptext"
|
||||
parts = [p.strip().strip('"') for p in line.split(',', 2)]
|
||||
if len(parts) >= 2:
|
||||
key = parts[0]
|
||||
|
||||
# If key is 'title', map directly to the section
|
||||
if key == 'title':
|
||||
full_key = current_section
|
||||
else:
|
||||
full_key = f"{current_section}.{key}" if current_section else key
|
||||
|
||||
# Use the provided human-readable name or fallback to key
|
||||
human_readable_name = parts[1] if parts[1] else key
|
||||
field_mapping[full_key] = human_readable_name
|
||||
|
||||
# Handle help text or default
|
||||
help = parts[2] if len(parts) == 3 and parts[2] else "No help available."
|
||||
help_text[full_key] = help
|
||||
|
||||
else:
|
||||
# Handle cases with only the key present
|
||||
full_key = f"{current_section}.{key}" if current_section else key
|
||||
field_mapping[full_key] = key
|
||||
help_text[full_key] = "No help available."
|
||||
|
||||
return field_mapping, help_text
|
||||
|
||||
def transform_menu_path(menu_path):
|
||||
"""Applies path replacements and normalizes entries in the menu path."""
|
||||
path_replacements = {
|
||||
"Radio Settings": "config",
|
||||
"Module Settings": "module"
|
||||
}
|
||||
|
||||
transformed_path = []
|
||||
for part in menu_path[1:]: # Skip 'Main Menu'
|
||||
# Apply fixed replacements
|
||||
part = path_replacements.get(part, part)
|
||||
|
||||
# Normalize entries like "Channel 1", "Channel 2", etc.
|
||||
if re.match(r'Channel\s+\d+', part, re.IGNORECASE):
|
||||
part = "channel"
|
||||
|
||||
transformed_path.append(part)
|
||||
|
||||
return transformed_path
|
||||
@@ -1,12 +1,11 @@
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from utilities.utils import decimal_to_hex
|
||||
import default_config as config
|
||||
import globals
|
||||
from contact.utilities.utils import decimal_to_hex
|
||||
import contact.ui.default_config as config
|
||||
import contact.globals as globals
|
||||
|
||||
def get_table_name(channel):
|
||||
# Construct the table name
|
||||
@@ -14,26 +13,22 @@ def get_table_name(channel):
|
||||
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, user_id, message_text):
|
||||
"""Save messages to the database, ensuring the table exists."""
|
||||
try:
|
||||
quoted_table_name = get_table_name(channel)
|
||||
|
||||
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:
|
||||
db_cursor = db_connection.cursor()
|
||||
|
||||
quoted_table_name = get_table_name(channel)
|
||||
|
||||
# Ensure the table exists
|
||||
create_table_query = f'''
|
||||
CREATE TABLE IF NOT EXISTS {quoted_table_name} (
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
)
|
||||
'''
|
||||
|
||||
db_cursor.execute(create_table_query)
|
||||
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Insert the message
|
||||
@@ -48,10 +43,10 @@ def save_message_to_db(channel, user_id, message_text):
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in save_message_to_db: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in save_message_to_db: {e}")
|
||||
|
||||
|
||||
def update_ack_nak(channel, timestamp, message, ack):
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
@@ -74,15 +69,12 @@ def update_ack_nak(channel, timestamp, message, ack):
|
||||
logging.error(f"Unexpected error in update_ack_nak: {e}")
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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()
|
||||
|
||||
# Retrieve all table names that match the pattern
|
||||
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
|
||||
db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
|
||||
tables = [row[0] for row in db_cursor.fetchall()]
|
||||
@@ -105,11 +97,11 @@ def load_messages_from_db():
|
||||
# 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
|
||||
# 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:
|
||||
if channel not in globals.channel_list and not is_chat_archived(channel):
|
||||
globals.channel_list.append(channel)
|
||||
|
||||
# Ensure the channel exists in globals.all_messages
|
||||
@@ -152,140 +144,139 @@ def load_messages_from_db():
|
||||
|
||||
def init_nodedb():
|
||||
"""Initialize the node database and update it with nodes from the interface."""
|
||||
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
if not globals.interface.nodes:
|
||||
return # No nodes to initialize
|
||||
|
||||
# Table name construction
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote the table name because it might begin with numerics
|
||||
ensure_node_table_exists() # Ensure the table exists before insertion
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
|
||||
# Create the table if it doesn't exist
|
||||
create_table_query = f'''
|
||||
CREATE TABLE IF NOT EXISTS {nodeinfo_table} (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
long_name TEXT,
|
||||
short_name TEXT,
|
||||
hw_model TEXT,
|
||||
is_licensed TEXT,
|
||||
role TEXT,
|
||||
public_key TEXT
|
||||
)
|
||||
'''
|
||||
db_cursor.execute(create_table_query)
|
||||
# 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', '')
|
||||
)
|
||||
|
||||
# Iterate over nodes and insert them into the database
|
||||
if globals.interface.nodes:
|
||||
for node in globals.interface.nodes.values():
|
||||
role = node['user'].get('role', 'CLIENT')
|
||||
is_licensed = node['user'].get('isLicensed', '0')
|
||||
public_key = node['user'].get('publicKey', '')
|
||||
|
||||
insert_query = f'''
|
||||
INSERT OR IGNORE INTO {nodeinfo_table} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
'''
|
||||
|
||||
db_cursor.execute(insert_query, (
|
||||
node['num'],
|
||||
node['user']['longName'],
|
||||
node['user']['shortName'],
|
||||
node['user']['hwModel'],
|
||||
is_licensed,
|
||||
role,
|
||||
public_key
|
||||
))
|
||||
|
||||
db_connection.commit()
|
||||
logging.info("Node database initialized successfully.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in init_nodedb: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in init_nodedb: {e}")
|
||||
|
||||
|
||||
def maybe_store_nodeinfo_in_db(packet):
|
||||
"""Save nodeinfo unless that record is already there."""
|
||||
"""Save nodeinfo unless that record is already there, updating if necessary."""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote the table name becuase we might begin with numerics
|
||||
db_cursor = db_connection.cursor()
|
||||
|
||||
# Check if a record with the same user_id already exists
|
||||
existing_record = db_cursor.execute(f'SELECT * FROM {nodeinfo_table} WHERE user_id=?', (packet['from'],)).fetchone()
|
||||
|
||||
if existing_record is None:
|
||||
role = packet['decoded']['user'].get('role', 'CLIENT')
|
||||
is_licensed = packet['decoded']['user'].get('isLicensed', '0')
|
||||
public_key = packet['decoded']['user'].get('publicKey', '')
|
||||
|
||||
# No existing record, insert the new record
|
||||
insert_query = f'''
|
||||
INSERT INTO {nodeinfo_table} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
'''
|
||||
|
||||
db_cursor.execute(insert_query, (
|
||||
packet['from'],
|
||||
packet['decoded']['user']['longName'],
|
||||
packet['decoded']['user']['shortName'],
|
||||
packet['decoded']['user']['hwModel'],
|
||||
is_licensed,
|
||||
role,
|
||||
public_key
|
||||
))
|
||||
|
||||
db_connection.commit()
|
||||
|
||||
else:
|
||||
# Check if values are different, update if necessary
|
||||
# Extract existing values
|
||||
existing_long_name = existing_record[1]
|
||||
existing_short_name = existing_record[2]
|
||||
existing_is_licensed = existing_record[4]
|
||||
existing_role = existing_record[5]
|
||||
existing_public_key = existing_record[6]
|
||||
|
||||
# Extract new values from the packet
|
||||
new_long_name = packet['decoded']['user']['longName']
|
||||
new_short_name = packet['decoded']['user']['shortName']
|
||||
new_is_licensed = packet['decoded']['user'].get('isLicensed', '0')
|
||||
new_role = packet['decoded']['user'].get('role', 'CLIENT')
|
||||
new_public_key = packet['decoded']['user'].get('publicKey', '')
|
||||
|
||||
# Check for any differences
|
||||
if (
|
||||
existing_long_name != new_long_name or
|
||||
existing_short_name != new_short_name or
|
||||
existing_is_licensed != new_is_licensed or
|
||||
existing_role != new_role or
|
||||
existing_public_key != new_public_key
|
||||
):
|
||||
# Perform necessary updates
|
||||
update_query = f'''
|
||||
UPDATE {nodeinfo_table}
|
||||
SET long_name = ?, short_name = ?, is_licensed = ?, role = ?, public_key = ?
|
||||
WHERE user_id = ?
|
||||
'''
|
||||
db_cursor.execute(update_query, (
|
||||
new_long_name,
|
||||
new_short_name,
|
||||
new_is_licensed,
|
||||
new_role,
|
||||
new_public_key,
|
||||
packet['from']
|
||||
))
|
||||
|
||||
db_connection.commit()
|
||||
|
||||
# TODO display new node name in nodelist
|
||||
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)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in maybe_store_nodeinfo_in_db: {e}")
|
||||
finally:
|
||||
db_connection.close()
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}")
|
||||
|
||||
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'"{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})')]
|
||||
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,))
|
||||
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:]
|
||||
|
||||
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
|
||||
hw_model = hw_model if hw_model is not None else existing_hw_model
|
||||
is_licensed = is_licensed if is_licensed is not None else existing_is_licensed
|
||||
role = role if role is not None else existing_role
|
||||
public_key = public_key if public_key is not None else existing_public_key
|
||||
chat_archived = chat_archived if chat_archived is not None else existing_chat_archived
|
||||
|
||||
long_name = long_name if long_name is not None else "Meshtastic " + str(decimal_to_hex(user_id)[-4:])
|
||||
short_name = short_name if short_name is not None else str(decimal_to_hex(user_id)[-4:])
|
||||
hw_model = hw_model if hw_model is not None else "UNSET"
|
||||
is_licensed = is_licensed if is_licensed is not None else 0
|
||||
role = role if role is not None else "CLIENT"
|
||||
public_key = public_key if public_key is not None else ""
|
||||
chat_archived = chat_archived if chat_archived is not None else 0
|
||||
|
||||
# Upsert logic
|
||||
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
|
||||
long_name = excluded.long_name,
|
||||
short_name = excluded.short_name,
|
||||
hw_model = excluded.hw_model,
|
||||
is_licensed = excluded.is_licensed,
|
||||
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_connection.commit()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in update_node_info_in_db: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in update_node_info_in_db: {e}")
|
||||
|
||||
|
||||
def ensure_node_table_exists():
|
||||
"""Ensure the node database table exists."""
|
||||
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote for safety
|
||||
schema = '''
|
||||
user_id TEXT PRIMARY KEY,
|
||||
long_name TEXT,
|
||||
short_name TEXT,
|
||||
hw_model TEXT,
|
||||
is_licensed TEXT,
|
||||
role TEXT,
|
||||
public_key TEXT,
|
||||
chat_archived INTEGER
|
||||
'''
|
||||
ensure_table_exists(table_name, schema)
|
||||
|
||||
|
||||
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:
|
||||
db_cursor = db_connection.cursor()
|
||||
create_table_query = f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})"
|
||||
db_cursor.execute(create_table_query)
|
||||
db_connection.commit()
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in ensure_table_exists({table_name}): {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in ensure_table_exists({table_name}): {e}")
|
||||
|
||||
|
||||
def get_name_from_database(user_id, type="long"):
|
||||
@@ -320,4 +311,25 @@ def get_name_from_database(user_id, type="long"):
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in get_name_from_database: {e}")
|
||||
return "Unknown"
|
||||
return "Unknown"
|
||||
|
||||
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(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,))
|
||||
result = db_cursor.fetchone()
|
||||
|
||||
return result[0] if result else 0
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in is_chat_archived: {e}")
|
||||
return "Unknown"
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in is_chat_archived: {e}")
|
||||
return "Unknown"
|
||||
|
||||
454
contact/utilities/input_handlers.py
Normal file
454
contact/utilities/input_handlers.py
Normal file
@@ -0,0 +1,454 @@
|
||||
import base64
|
||||
import binascii
|
||||
import curses
|
||||
import ipaddress
|
||||
import re
|
||||
from contact.ui.colors import get_color
|
||||
|
||||
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)
|
||||
|
||||
if word_length > wrap_width: # Break long words
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer)
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
for i in range(0, word_length, wrap_width):
|
||||
wrapped_lines.append(word[i:i+wrap_width])
|
||||
continue
|
||||
|
||||
if line_length + word_length > wrap_width and word.strip():
|
||||
wrapped_lines.append(line_buffer)
|
||||
line_buffer = ""
|
||||
line_length = 0
|
||||
|
||||
line_buffer += word
|
||||
line_length += word_length
|
||||
|
||||
if line_buffer:
|
||||
wrapped_lines.append(line_buffer)
|
||||
|
||||
return wrapped_lines
|
||||
|
||||
|
||||
def get_text_input(prompt):
|
||||
"""Handles user input with wrapped text for long prompts."""
|
||||
height = 8
|
||||
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 = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
input_win = curses.newwin(height, width, start_y, start_x)
|
||||
input_win.bkgd(get_color("background"))
|
||||
input_win.attrset(get_color("window_frame"))
|
||||
input_win.border()
|
||||
|
||||
# Wrap the prompt text
|
||||
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
|
||||
row = 1
|
||||
for line in wrapped_prompt:
|
||||
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
||||
row += 1
|
||||
if row >= height - 3: # Prevent overflow
|
||||
break
|
||||
|
||||
prompt_text = "Enter new value: "
|
||||
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||
|
||||
input_win.refresh()
|
||||
curses.curs_set(1)
|
||||
|
||||
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) # Available space for first line
|
||||
|
||||
while True:
|
||||
key = input_win.get_wch() # Waits for user input
|
||||
|
||||
if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
input_win.erase()
|
||||
input_win.refresh()
|
||||
curses.curs_set(0)
|
||||
return None # Exit without saving
|
||||
|
||||
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: # 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, " " * min(input_width, width - margin - 1), get_color("settings_default"))
|
||||
|
||||
# Redraw the prompt text so it never disappears
|
||||
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||
|
||||
# Redraw wrapped input
|
||||
input_win.addstr(row + 1, col_start, first_line, get_color("settings_default")) # First line next to prompt
|
||||
for i, line in enumerate(wrapped_lines):
|
||||
if row + 2 + i < height - 1:
|
||||
input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default"))
|
||||
|
||||
input_win.refresh()
|
||||
|
||||
curses.curs_set(0)
|
||||
input_win.erase()
|
||||
input_win.refresh()
|
||||
return user_input
|
||||
|
||||
|
||||
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."""
|
||||
try:
|
||||
decoded = base64.b64decode(s, validate=True)
|
||||
return len(decoded) == 32 # Ensure it's exactly 32 bytes
|
||||
except binascii.Error:
|
||||
return False
|
||||
|
||||
cvalue = to_base64(current_value) # Convert current values to Base64
|
||||
height = 9
|
||||
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.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)
|
||||
|
||||
# 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
|
||||
error_message = ""
|
||||
|
||||
while True:
|
||||
repeated_win.erase()
|
||||
repeated_win.border()
|
||||
repeated_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
|
||||
|
||||
# Display current values, allowing editing
|
||||
for i, line in enumerate(user_values):
|
||||
prefix = "→ " if i == cursor_pos else " " # Highlight the current line
|
||||
repeated_win.addstr(3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
|
||||
repeated_win.addstr(3 + i, 18, line) # Align text for easier editing
|
||||
|
||||
# Move cursor to the correct position inside the field
|
||||
curses.curs_set(1)
|
||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||
|
||||
# Show error message if needed
|
||||
if error_message:
|
||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
||||
|
||||
repeated_win.refresh()
|
||||
key = repeated_win.getch()
|
||||
|
||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
|
||||
repeated_win.erase()
|
||||
repeated_win.refresh()
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return None
|
||||
|
||||
elif key == ord('\n'): # Enter key to save and return
|
||||
if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return user_values # Return the edited Base64 values
|
||||
else:
|
||||
error_message = "Error: Each key must be valid Base64 and 32 bytes long!"
|
||||
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:
|
||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
||||
error_message = "" # Clear error if user starts fixing input
|
||||
except ValueError:
|
||||
pass # Ignore invalid character inputs
|
||||
|
||||
|
||||
|
||||
def get_repeated_input(current_value):
|
||||
height = 9
|
||||
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.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) # Show the cursor
|
||||
|
||||
# Editable list of values (max 3 values)
|
||||
user_values = current_value[:3]
|
||||
cursor_pos = 0 # Track which value is being edited
|
||||
error_message = ""
|
||||
|
||||
while True:
|
||||
repeated_win.erase()
|
||||
repeated_win.border()
|
||||
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
|
||||
|
||||
# Display current values, allowing editing
|
||||
for i, line in enumerate(user_values):
|
||||
prefix = "→ " if i == cursor_pos else " " # Highlight the current line
|
||||
repeated_win.addstr(3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
|
||||
repeated_win.addstr(3 + i, 18, line)
|
||||
|
||||
# Move cursor to the correct position inside the field
|
||||
curses.curs_set(1)
|
||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||
|
||||
# Show error message if needed
|
||||
if error_message:
|
||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
||||
|
||||
repeated_win.refresh()
|
||||
key = repeated_win.getch()
|
||||
|
||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
|
||||
repeated_win.erase()
|
||||
repeated_win.refresh()
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return None
|
||||
|
||||
elif key == ord('\n'): # Enter key to save and return
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
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:
|
||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
||||
error_message = "" # Clear error if user starts fixing input
|
||||
except ValueError:
|
||||
pass # Ignore invalid character inputs
|
||||
|
||||
|
||||
def get_fixed32_input(current_value):
|
||||
cvalue = current_value
|
||||
current_value = str(ipaddress.IPv4Address(current_value))
|
||||
height = 10
|
||||
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)
|
||||
|
||||
curses.echo()
|
||||
curses.curs_set(1)
|
||||
user_input = ""
|
||||
|
||||
while True:
|
||||
fixed32_win.erase()
|
||||
fixed32_win.border()
|
||||
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
|
||||
fixed32_win.addstr(3, 2, f"Current: {current_value}")
|
||||
fixed32_win.addstr(5, 2, f"New value: {user_input}")
|
||||
fixed32_win.refresh()
|
||||
|
||||
key = fixed32_win.getch()
|
||||
|
||||
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)
|
||||
return cvalue # Return the current value unchanged
|
||||
elif key == ord('\n'): # Enter key to validate and save
|
||||
# Validate IP address
|
||||
octets = user_input.split(".")
|
||||
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
fixed32_address = ipaddress.ip_address(user_input)
|
||||
return int(fixed32_address) # Return the valid IP address
|
||||
else:
|
||||
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
|
||||
fixed32_win.refresh()
|
||||
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:
|
||||
char = chr(key)
|
||||
if char.isdigit() or char == ".":
|
||||
user_input += char # Append only valid characters (digits or dots)
|
||||
except ValueError:
|
||||
pass # Ignore invalid inputs
|
||||
|
||||
|
||||
def get_list_input(prompt, current_option, list_options):
|
||||
"""
|
||||
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 = 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.bkgd(get_color("background"))
|
||||
list_win.attrset(get_color("window_frame"))
|
||||
list_win.keypad(True)
|
||||
|
||||
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
|
||||
|
||||
draw_arrows(list_win, visible_height, max_index, 0)
|
||||
|
||||
while True:
|
||||
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, 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, selected_index, list_options, list_win, list_pad)
|
||||
elif key == ord('\n'): # Enter key
|
||||
list_win.clear()
|
||||
list_win.refresh()
|
||||
return list_options[selected_index]
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
list_win.clear()
|
||||
list_win.refresh()
|
||||
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,6 +1,6 @@
|
||||
import logging
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
import globals
|
||||
import contact.globals as globals
|
||||
|
||||
def initialize_interface(args):
|
||||
try:
|
||||
@@ -16,7 +16,7 @@ def initialize_interface(args):
|
||||
except Exception as ex:
|
||||
logging.error(f"Unexpected error initializing interface: {ex}")
|
||||
if globals.interface.devPath is None:
|
||||
return meshtastic.tcp_interface.TCPInterface("meshtastic.local")
|
||||
return meshtastic.tcp_interface.TCPInterface("localhost")
|
||||
|
||||
except Exception as ex:
|
||||
logging.critical(f"Fatal error initializing interface: {ex}")
|
||||
@@ -2,26 +2,9 @@ from meshtastic.protobuf import channel_pb2
|
||||
from google.protobuf.message import Message
|
||||
import logging
|
||||
import base64
|
||||
import time
|
||||
|
||||
def settings_reboot(interface):
|
||||
interface.localNode.reboot()
|
||||
|
||||
def settings_reset_nodedb(interface):
|
||||
interface.localNode.resetNodeDb()
|
||||
|
||||
def settings_shutdown(interface):
|
||||
interface.localNode.shutdown()
|
||||
|
||||
def settings_factory_reset(interface):
|
||||
interface.localNode.factoryReset()
|
||||
|
||||
# def settings_set_owner(interface, long_name=None, short_name=None, is_licensed=False):
|
||||
# if isinstance(is_licensed, str):
|
||||
# is_licensed = is_licensed.lower() == 'true'
|
||||
# interface.localNode.setOwner(long_name, short_name, is_licensed)
|
||||
|
||||
|
||||
def save_changes(interface, menu_path, modified_settings):
|
||||
def save_changes(interface, modified_settings, state):
|
||||
"""
|
||||
Save changes to the device based on modified settings.
|
||||
:param interface: Meshtastic interface instance
|
||||
@@ -34,9 +17,43 @@ def save_changes(interface, menu_path, modified_settings):
|
||||
return
|
||||
|
||||
node = interface.getNode('^local')
|
||||
admin_key_backup = None
|
||||
if 'admin_key' in modified_settings:
|
||||
# Get reference to security config
|
||||
security_config = node.localConfig.security
|
||||
admin_keys = modified_settings['admin_key']
|
||||
|
||||
if menu_path[1] == "Radio Settings" or menu_path[1] == "Module Settings":
|
||||
config_category = menu_path[2].lower() # for radio and module configs
|
||||
# Filter out empty keys
|
||||
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.")
|
||||
else:
|
||||
# Clear existing keys if needed
|
||||
if security_config.admin_key:
|
||||
logging.info("Clearing existing admin keys...")
|
||||
del security_config.admin_key[:]
|
||||
node.writeConfig("security")
|
||||
time.sleep(2) # Give time for device to process
|
||||
|
||||
# Append new keys
|
||||
for key in valid_keys:
|
||||
logging.info(f"Adding admin key: {key}")
|
||||
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)
|
||||
# Remove 'admin_key' from modified_settings to prevent interference
|
||||
del modified_settings['admin_key']
|
||||
|
||||
# Return early if there are no other settings left to process
|
||||
if not modified_settings:
|
||||
return
|
||||
|
||||
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))
|
||||
@@ -47,23 +64,24 @@ def save_changes(interface, menu_path, modified_settings):
|
||||
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
||||
return
|
||||
|
||||
elif menu_path[1] == "User Settings": # for user configs
|
||||
config_category = "User Settings"
|
||||
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")
|
||||
is_licensed = modified_settings.get("isLicensed")
|
||||
is_licensed = is_licensed == "True" or is_licensed is True
|
||||
is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed)
|
||||
|
||||
logging.info(f"Updated {config_category} with Long Name: {long_name} and Short Name {short_name} and 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_path[1] == "Channels": # for channel configs
|
||||
elif state.menu_path[1] == "Channels": # for channel configs
|
||||
config_category = "Channels"
|
||||
|
||||
try:
|
||||
channel = menu_path[-1]
|
||||
channel = state.menu_path[-1]
|
||||
channel_num = int(channel.split()[-1]) - 1
|
||||
except (IndexError, ValueError) as e:
|
||||
channel_num = None
|
||||
@@ -131,13 +149,11 @@ def save_changes(interface, menu_path, modified_settings):
|
||||
try:
|
||||
node.writeConfig(config_category)
|
||||
logging.info(f"Changes written to config category: {config_category}")
|
||||
|
||||
if admin_key_backup is not None:
|
||||
modified_settings['admin_key'] = admin_key_backup
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
|
||||
|
||||
|
||||
node.writeConfig(config_category)
|
||||
|
||||
logging.info(f"Changes written to config category: {config_category}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving changes: {e}")
|
||||
logging.error(f"Error saving changes: {e}")
|
||||
124
contact/utilities/utils.py
Normal file
124
contact/utilities/utils.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import contact.globals as globals
|
||||
import datetime
|
||||
from meshtastic.protobuf import config_pb2
|
||||
import contact.ui.default_config as config
|
||||
|
||||
def get_channels():
|
||||
"""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
|
||||
# globals.channel_list = []
|
||||
|
||||
for device_channel in device_channels:
|
||||
if device_channel.role:
|
||||
# Use the channel name if available, otherwise use the modem preset
|
||||
if device_channel.settings.name:
|
||||
channel_name = device_channel.settings.name
|
||||
else:
|
||||
# If channel name is blank, use the modem preset
|
||||
lora_config = node.localConfig.lora
|
||||
modem_preset_enum = lora_config.modem_preset
|
||||
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
|
||||
channel_name = convert_to_camel_case(modem_preset_string)
|
||||
|
||||
# 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 globals.all_messages[channel_name] if it doesn't exist
|
||||
if channel_name not in globals.all_messages:
|
||||
globals.all_messages[channel_name] = []
|
||||
|
||||
|
||||
return globals.channel_list
|
||||
|
||||
def get_node_list():
|
||||
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
|
||||
else:
|
||||
return node
|
||||
|
||||
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)
|
||||
|
||||
# Move ignored nodes to the end
|
||||
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]
|
||||
return [my_node_num] + node_list # Ensuring your node is always first
|
||||
return []
|
||||
|
||||
def refresh_node_list():
|
||||
new_node_list = get_node_list()
|
||||
if new_node_list != globals.node_list:
|
||||
globals.node_list = new_node_list
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_nodeNum():
|
||||
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)
|
||||
return camel_case_string
|
||||
|
||||
def get_time_val_units(time_delta):
|
||||
value = 0
|
||||
unit = ""
|
||||
|
||||
if time_delta.days > 365:
|
||||
value = time_delta.days // 365
|
||||
unit = "y"
|
||||
elif time_delta.days > 30:
|
||||
value = time_delta.days // 30
|
||||
unit = "mon"
|
||||
elif time_delta.days > 7:
|
||||
value = time_delta.days // 7
|
||||
unit = "w"
|
||||
elif time_delta.days > 0:
|
||||
value = time_delta.days
|
||||
unit = "d"
|
||||
elif time_delta.seconds > 3600:
|
||||
value = time_delta.seconds // 3600
|
||||
unit = "h"
|
||||
elif time_delta.seconds > 60:
|
||||
value = time_delta.seconds // 60
|
||||
unit = "min"
|
||||
else:
|
||||
value = time_delta.seconds
|
||||
unit = "s"
|
||||
return (value, unit)
|
||||
|
||||
def get_readable_duration(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)
|
||||
delta = now - dt
|
||||
|
||||
value, unit = get_time_val_units(delta)
|
||||
if unit != "s":
|
||||
return f"{value} {unit} ago"
|
||||
return "now"
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import curses
|
||||
import ipaddress
|
||||
from ui.colors import get_color
|
||||
|
||||
def get_user_input(prompt):
|
||||
# Calculate the dynamic height and width for the input window
|
||||
height = 7 # Fixed height for input prompt
|
||||
width = 60
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create a new window for user input
|
||||
input_win = curses.newwin(height, width, start_y, start_x)
|
||||
input_win.bkgd(get_color("background"))
|
||||
input_win.attrset(get_color("window_frame"))
|
||||
input_win.border()
|
||||
|
||||
# Display the prompt
|
||||
input_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
|
||||
input_win.addstr(3, 2, "Enter value: ", get_color("settings_default"))
|
||||
input_win.refresh()
|
||||
|
||||
# Check if "shortName" is in the prompt, and set max length accordingly
|
||||
max_length = 4 if "shortName" in prompt else None
|
||||
|
||||
curses.curs_set(1)
|
||||
|
||||
user_input = ""
|
||||
input_position = (3, 15) # Tuple for row and column
|
||||
row, col = input_position # Unpack tuple
|
||||
while True:
|
||||
key = input_win.get_wch(row, col + len(user_input)) # Adjust cursor position dynamically
|
||||
if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
curses.curs_set(0)
|
||||
return None # 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)): # Backspace
|
||||
user_input = user_input[:-1]
|
||||
input_win.addstr(row, col, " " * (len(user_input) + 1), get_color("settings_default")) # Clear the line
|
||||
input_win.addstr(row, col, user_input, get_color("settings_default"))
|
||||
elif max_length is None or len(user_input) < max_length: # Enforce max length if applicable
|
||||
# Append typed character to input text
|
||||
if(isinstance(key, str)):
|
||||
user_input += key
|
||||
else:
|
||||
user_input += chr(key)
|
||||
input_win.addstr(3, 15, user_input, get_color("settings_default"))
|
||||
|
||||
curses.curs_set(0)
|
||||
|
||||
# Clear the input window
|
||||
input_win.erase()
|
||||
input_win.refresh()
|
||||
return user_input
|
||||
|
||||
def get_bool_selection(message, current_value):
|
||||
message = "Select True or False:" if None else message
|
||||
cvalue = current_value
|
||||
options = ["True", "False"]
|
||||
selected_index = 0 if current_value == "True" else 1
|
||||
|
||||
height = 7
|
||||
width = 60
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
bool_win = curses.newwin(height, width, start_y, start_x)
|
||||
bool_win.bkgd(get_color("background"))
|
||||
bool_win.attrset(get_color("window_frame"))
|
||||
bool_win.keypad(True)
|
||||
bool_win.erase()
|
||||
|
||||
bool_win.border()
|
||||
bool_win.addstr(1, 2, message, get_color("settings_default", bold=True))
|
||||
|
||||
for idx, option in enumerate(options):
|
||||
if idx == selected_index:
|
||||
bool_win.addstr(idx + 3, 4, option, get_color("settings_default", reverse=True))
|
||||
else:
|
||||
bool_win.addstr(idx + 3, 4, option, get_color("settings_default"))
|
||||
|
||||
bool_win.refresh()
|
||||
|
||||
while True:
|
||||
key = bool_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
if(selected_index > 0):
|
||||
selected_index = selected_index - 1
|
||||
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default"))
|
||||
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default", reverse = True))
|
||||
elif key == curses.KEY_DOWN:
|
||||
if(selected_index < len(options) - 1):
|
||||
selected_index = selected_index + 1
|
||||
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default"))
|
||||
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default", reverse = True))
|
||||
elif key == ord('\n'): # Enter key
|
||||
return options[selected_index]
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
return cvalue
|
||||
|
||||
def get_repeated_input(current_value):
|
||||
cvalue = current_value
|
||||
height = 10
|
||||
width = 60
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
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)
|
||||
user_input = ""
|
||||
|
||||
while True:
|
||||
repeated_win.erase()
|
||||
repeated_win.border()
|
||||
repeated_win.addstr(1, 2, "Enter comma-separated values:", get_color("settings_default", bold=True))
|
||||
repeated_win.addstr(3, 2, f"Current: {', '.join(map(str, current_value))}", get_color("settings_default"))
|
||||
repeated_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default"))
|
||||
repeated_win.refresh()
|
||||
|
||||
key = repeated_win.getch()
|
||||
|
||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return cvalue # Return the current value without changes
|
||||
elif key == ord('\n'): # Enter key to save and return
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return user_input.split(",") # Split the input into a list
|
||||
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
|
||||
user_input = user_input[:-1]
|
||||
else:
|
||||
try:
|
||||
user_input += chr(key) # Append valid character input
|
||||
except ValueError:
|
||||
pass # Ignore invalid character inputs
|
||||
|
||||
def move_highlight(old_idx, new_idx, options, enum_win, enum_pad):
|
||||
if old_idx == new_idx:
|
||||
return # no-op
|
||||
|
||||
enum_pad.chgat(old_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default"))
|
||||
enum_pad.chgat(new_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
|
||||
|
||||
enum_win.refresh()
|
||||
|
||||
start_index = max(0, new_idx - (enum_win.getmaxyx()[0] - 4))
|
||||
|
||||
enum_win.refresh()
|
||||
enum_pad.refresh(start_index, 0,
|
||||
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
|
||||
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + 4 + enum_win.getmaxyx()[1] - 4)
|
||||
|
||||
def get_enum_input(options, current_value):
|
||||
selected_index = options.index(current_value) if current_value in options else 0
|
||||
|
||||
height = min(len(options) + 4, curses.LINES - 2)
|
||||
width = 60
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
enum_win = curses.newwin(height, width, start_y, start_x)
|
||||
enum_win.bkgd(get_color("background"))
|
||||
enum_win.attrset(get_color("window_frame"))
|
||||
enum_win.keypad(True)
|
||||
|
||||
enum_pad = curses.newpad(len(options) + 1, width - 8)
|
||||
enum_pad.bkgd(get_color("background"))
|
||||
|
||||
enum_win.erase()
|
||||
enum_win.border()
|
||||
enum_win.addstr(1, 2, "Select an option:", get_color("settings_default", bold=True))
|
||||
|
||||
for idx, option in enumerate(options):
|
||||
if idx == selected_index:
|
||||
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default", reverse=True))
|
||||
else:
|
||||
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default"))
|
||||
|
||||
enum_win.refresh()
|
||||
enum_pad.refresh(0, 0,
|
||||
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
|
||||
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + enum_win.getmaxyx()[1] - 4)
|
||||
|
||||
while True:
|
||||
key = enum_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
old_selected_index = selected_index
|
||||
selected_index = max(0, selected_index - 1)
|
||||
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
|
||||
elif key == curses.KEY_DOWN:
|
||||
old_selected_index = selected_index
|
||||
selected_index = min(len(options) - 1, selected_index + 1)
|
||||
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
|
||||
elif key == ord('\n'): # Enter key
|
||||
return options[selected_index]
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
return current_value
|
||||
|
||||
|
||||
def get_fixed32_input(current_value):
|
||||
cvalue = current_value
|
||||
current_value = str(ipaddress.IPv4Address(current_value))
|
||||
height = 10
|
||||
width = 60
|
||||
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)
|
||||
|
||||
curses.echo()
|
||||
curses.curs_set(1)
|
||||
user_input = ""
|
||||
|
||||
while True:
|
||||
fixed32_win.erase()
|
||||
fixed32_win.border()
|
||||
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
|
||||
fixed32_win.addstr(3, 2, f"Current: {current_value}")
|
||||
fixed32_win.addstr(5, 2, f"New value: {user_input}")
|
||||
fixed32_win.refresh()
|
||||
|
||||
key = fixed32_win.getch()
|
||||
|
||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow to cancel
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return cvalue # Return the current value unchanged
|
||||
elif key == ord('\n'): # Enter key to validate and save
|
||||
# Validate IP address
|
||||
octets = user_input.split(".")
|
||||
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
fixed32_address = ipaddress.ip_address(user_input)
|
||||
return int(fixed32_address) # Return the valid IP address
|
||||
else:
|
||||
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
|
||||
fixed32_win.refresh()
|
||||
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:
|
||||
char = chr(key)
|
||||
if char.isdigit() or char == ".":
|
||||
user_input += char # Append only valid characters (digits or dots)
|
||||
except ValueError:
|
||||
pass # Ignore invalid inputs
|
||||
|
||||
|
||||
|
||||
def select_from_list(prompt, current_option, list_options):
|
||||
"""
|
||||
Displays a scrollable list of list_options for the user to choose from using a pad.
|
||||
"""
|
||||
selected_index = list_options.index(current_option) if current_option in list_options else 0
|
||||
|
||||
height = min(len(list_options) + 5, curses.LINES - 2)
|
||||
width = 60
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
list_win = curses.newwin(height, width, start_y, start_x)
|
||||
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, width - 8)
|
||||
list_pad.bkgd(get_color("background"))
|
||||
|
||||
# Render header
|
||||
list_win.clear()
|
||||
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)
|
||||
|
||||
while True:
|
||||
key = list_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
|
||||
if selected_index > 0:
|
||||
selected_index -= 1
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
if selected_index < len(list_options) - 1:
|
||||
selected_index += 1
|
||||
|
||||
elif key == curses.KEY_RIGHT or key == ord('\n'):
|
||||
return list_options[selected_index]
|
||||
|
||||
elif key == curses.KEY_LEFT or key == 27: # ESC key
|
||||
return current_option
|
||||
|
||||
# Refresh the pad with updated selection and scroll offset
|
||||
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"))
|
||||
|
||||
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)
|
||||
64
main.py
64
main.py
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
'''
|
||||
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
|
||||
Powered by Meshtastic.org
|
||||
V 1.2.0
|
||||
'''
|
||||
|
||||
import curses
|
||||
from pubsub import pub
|
||||
import os
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from utilities.arg_parser import setup_parser
|
||||
from utilities.interfaces import initialize_interface
|
||||
from message_handlers.rx_handler import on_receive
|
||||
from ui.curses_ui import main_ui, draw_splash
|
||||
from utilities.utils import get_channels, get_node_list, get_nodeNum
|
||||
from db_handler import init_nodedb, load_messages_from_db
|
||||
import default_config as config
|
||||
import globals
|
||||
|
||||
# Set environment variables for ncurses compatibility
|
||||
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
|
||||
os.environ["TERM"] = "screen"
|
||||
os.environ["LANG"] = "C.UTF-8"
|
||||
|
||||
# Configure logging
|
||||
# Run `tail -f client.log` in another terminal to view live
|
||||
logging.basicConfig(
|
||||
filename=config.log_file_path,
|
||||
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
def main(stdscr):
|
||||
try:
|
||||
draw_splash(stdscr)
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.info("Initializing interface %s", args)
|
||||
globals.interface = initialize_interface(args)
|
||||
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")
|
||||
main_ui(stdscr)
|
||||
except Exception as e:
|
||||
logging.error("An error occurred: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except Exception as e:
|
||||
logging.error("Fatal error in curses wrapper: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
@@ -1,102 +0,0 @@
|
||||
import logging
|
||||
import time
|
||||
from utilities.utils import get_node_list
|
||||
from ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification
|
||||
from db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database
|
||||
import default_config as config
|
||||
import globals
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
def on_receive(packet, interface):
|
||||
|
||||
# Update packet log
|
||||
globals.packet_buffer.append(packet)
|
||||
if len(globals.packet_buffer) > 20:
|
||||
# Trim buffer to 20 packets
|
||||
globals.packet_buffer = globals.packet_buffer[-20:]
|
||||
|
||||
if globals.display_log:
|
||||
draw_packetlog_win()
|
||||
try:
|
||||
if 'decoded' not in packet:
|
||||
return
|
||||
|
||||
# Assume any incoming packet could update the last seen time for a node
|
||||
new_node_list = get_node_list()
|
||||
if new_node_list != globals.node_list:
|
||||
globals.node_list = new_node_list
|
||||
draw_node_list()
|
||||
|
||||
if packet['decoded']['portnum'] == 'NODEINFO_APP':
|
||||
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
|
||||
maybe_store_nodeinfo_in_db(packet)
|
||||
|
||||
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']
|
||||
else:
|
||||
channel_number = 0
|
||||
|
||||
if packet['to'] == globals.myNodeNum:
|
||||
if packet['from'] in globals.channel_list:
|
||||
pass
|
||||
else:
|
||||
globals.channel_list.append(packet['from'])
|
||||
globals.all_messages[packet['from']] = []
|
||||
refresh_channels = True
|
||||
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
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') + ":"
|
||||
|
||||
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(globals.channel_list[channel_number], message_from_id, message_string)
|
||||
|
||||
except KeyError as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.3.3"
|
||||
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
||||
authors = [
|
||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||
]
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9,<3.14"
|
||||
dependencies = [
|
||||
"meshtastic (>=2.6.0,<3.0.0)"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/pdxlocations/contact"
|
||||
Issues = "https://github.com/pdxlocations/contact/issues"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
contact = "contact.__main__:start"
|
||||
376
settings.py
376
settings.py
@@ -1,376 +0,0 @@
|
||||
import curses
|
||||
import logging
|
||||
import os
|
||||
|
||||
from save_to_radio import settings_factory_reset, settings_reboot, settings_reset_nodedb, settings_shutdown, save_changes
|
||||
from utilities.config_io import config_export, config_import
|
||||
from input_handlers import get_bool_selection, get_repeated_input, get_user_input, get_enum_input, get_fixed32_input, select_from_list
|
||||
from ui.menus import generate_menu_from_protobuf
|
||||
from ui.colors import setup_colors, get_color
|
||||
from utilities.arg_parser import setup_parser
|
||||
from utilities.interfaces import initialize_interface
|
||||
from user_config import json_editor
|
||||
import globals
|
||||
|
||||
width = 60
|
||||
save_option = "Save Changes"
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
|
||||
def display_menu(current_menu, menu_path, selected_index, show_save_option):
|
||||
|
||||
# Calculate the dynamic height based on the number of menu items
|
||||
num_items = len(current_menu) + (1 if show_save_option else 0) # Add 1 for the "Save Changes" option if applicable
|
||||
height = min(curses.LINES - 2, num_items + 5) # Ensure the menu fits within the terminal height
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create a new curses window with dynamic dimensions
|
||||
menu_win = curses.newwin(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(current_menu) + 1, width - 8)
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
# Display the current menu path as a header
|
||||
header = " > ".join(word.title() for word in menu_path)
|
||||
if len(header) > width - 4:
|
||||
header = header[:width - 7] + "..."
|
||||
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
|
||||
|
||||
# Display the menu options
|
||||
for idx, option in enumerate(current_menu):
|
||||
field_info = current_menu[option]
|
||||
current_value = field_info[1] if isinstance(field_info, tuple) else ""
|
||||
display_option = f"{option}"[:width // 2 - 2] # Truncate option name if too long``
|
||||
display_value = f"{current_value}"[:width // 2 - 4] # Truncate value if too long
|
||||
|
||||
try:
|
||||
# Use red color for "Reboot" or "Shutdown"
|
||||
color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse = (idx == selected_index))
|
||||
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Show save option if applicable
|
||||
if show_save_option:
|
||||
save_position = height - 2
|
||||
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse = (selected_index == len(current_menu))))
|
||||
|
||||
menu_win.refresh()
|
||||
menu_pad.refresh(0, 0,
|
||||
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8)
|
||||
|
||||
return menu_win, menu_pad
|
||||
|
||||
|
||||
def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_pad):
|
||||
|
||||
if(old_idx == new_idx): # no-op
|
||||
return
|
||||
|
||||
max_index = len(options) + (1 if show_save_option else 0) - 1
|
||||
|
||||
if show_save_option and old_idx == max_index: # special case un-highlight "Save" option
|
||||
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"))
|
||||
|
||||
if show_save_option and new_idx == max_index: # special case highlight "Save" option
|
||||
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse = True))
|
||||
else:
|
||||
menu_pad.chgat(new_idx, 0,menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse = True))
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)) - (1 if show_save_option and new_idx == max_index else 0)) # Leave room for borders
|
||||
menu_pad.refresh(start_index, 0,
|
||||
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8)
|
||||
|
||||
def settings_menu(stdscr, interface):
|
||||
curses.update_lines_cols()
|
||||
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
current_menu = menu["Main Menu"]
|
||||
menu_path = ["Main Menu"]
|
||||
menu_index = []
|
||||
selected_index = 0
|
||||
modified_settings = {}
|
||||
|
||||
need_redraw = True
|
||||
show_save_option = False
|
||||
|
||||
while True:
|
||||
if(need_redraw):
|
||||
options = list(current_menu.keys())
|
||||
|
||||
show_save_option = (
|
||||
len(menu_path) > 2 and ("Radio Settings" in menu_path or "Module Settings" in menu_path)
|
||||
) or (
|
||||
len(menu_path) == 2 and "User Settings" in menu_path
|
||||
) or (
|
||||
len(menu_path) == 3 and "Channels" in menu_path
|
||||
)
|
||||
|
||||
# Display the menu
|
||||
menu_win, menu_pad = display_menu(current_menu, menu_path, selected_index, show_save_option)
|
||||
|
||||
need_redraw = False
|
||||
|
||||
# Capture user input
|
||||
key = menu_win.getch()
|
||||
|
||||
max_index = len(options) + (1 if show_save_option else 0) - 1
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
old_selected_index = selected_index
|
||||
selected_index = max_index if selected_index == 0 else selected_index - 1
|
||||
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad)
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
old_selected_index = selected_index
|
||||
selected_index = 0 if selected_index == max_index else selected_index + 1
|
||||
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad)
|
||||
|
||||
elif key == curses.KEY_RESIZE:
|
||||
need_redraw = True
|
||||
curses.update_lines_cols()
|
||||
|
||||
elif key == ord("\t") and show_save_option:
|
||||
old_selected_index = selected_index
|
||||
selected_index = max_index
|
||||
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad)
|
||||
|
||||
elif key == curses.KEY_RIGHT or key == ord('\n'):
|
||||
need_redraw = True
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
if show_save_option and selected_index == len(options):
|
||||
save_changes(interface, menu_path, modified_settings)
|
||||
modified_settings.clear()
|
||||
logging.info("Changes Saved")
|
||||
|
||||
if len(menu_path) > 1:
|
||||
menu_path.pop()
|
||||
current_menu = menu["Main Menu"]
|
||||
for step in menu_path[1:]:
|
||||
current_menu = current_menu.get(step, {})
|
||||
selected_index = 0
|
||||
|
||||
continue
|
||||
|
||||
selected_option = options[selected_index]
|
||||
|
||||
if selected_option == "Exit":
|
||||
break
|
||||
|
||||
|
||||
elif selected_option == "Export Config":
|
||||
filename = get_user_input("Enter a filename for the config file")
|
||||
|
||||
if not filename:
|
||||
logging.warning("Export aborted: No filename provided.")
|
||||
continue # Go back to the menu
|
||||
|
||||
if not filename.lower().endswith(".yaml"):
|
||||
filename += ".yaml"
|
||||
|
||||
try:
|
||||
config_text = config_export(globals.interface)
|
||||
app_directory = os.path.dirname(os.path.abspath(__file__))
|
||||
config_folder = "node-configs"
|
||||
yaml_file_path = os.path.join(app_directory, config_folder, filename)
|
||||
|
||||
if os.path.exists(yaml_file_path):
|
||||
overwrite = get_bool_selection(f"{filename} already exists. Overwrite?", None)
|
||||
if overwrite == "False":
|
||||
logging.info("Export cancelled: User chose not to overwrite.")
|
||||
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}")
|
||||
break
|
||||
except PermissionError:
|
||||
logging.error(f"Permission denied: Unable to write to {yaml_file_path}")
|
||||
except OSError as e:
|
||||
logging.error(f"OS error while saving config: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error: {e}")
|
||||
continue
|
||||
|
||||
|
||||
|
||||
|
||||
elif selected_option == "Load Config":
|
||||
|
||||
app_directory = os.path.dirname(os.path.abspath(__file__))
|
||||
config_folder = "node-configs"
|
||||
folder_path = os.path.join(app_directory, config_folder)
|
||||
file_list = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]
|
||||
filename = select_from_list("Choose a config file", None, file_list)
|
||||
if filename:
|
||||
file_path = os.path.join(app_directory, config_folder, filename)
|
||||
overwrite = get_bool_selection(f"Are you sure you want to load {filename}?", None)
|
||||
if overwrite == "True":
|
||||
config_import(globals.interface, file_path)
|
||||
break
|
||||
continue
|
||||
|
||||
|
||||
|
||||
elif selected_option == "Reboot":
|
||||
confirmation = get_bool_selection("Are you sure you want to Reboot?", 0)
|
||||
if confirmation == "True":
|
||||
settings_reboot(interface)
|
||||
logging.info(f"Node Reboot Requested by menu")
|
||||
break
|
||||
continue
|
||||
elif selected_option == "Reset Node DB":
|
||||
confirmation = get_bool_selection("Are you sure you want to Reset Node DB?", 0)
|
||||
if confirmation == "True":
|
||||
settings_reset_nodedb(interface)
|
||||
logging.info(f"Node DB Reset Requested by menu")
|
||||
break
|
||||
continue
|
||||
elif selected_option == "Shutdown":
|
||||
confirmation = get_bool_selection("Are you sure you want to Shutdown?", 0)
|
||||
if confirmation == "True":
|
||||
settings_shutdown(interface)
|
||||
logging.info(f"Node Shutdown Requested by menu")
|
||||
break
|
||||
continue
|
||||
elif selected_option == "Factory Reset":
|
||||
confirmation = get_bool_selection("Are you sure you want to Factory Reset?", 0)
|
||||
if confirmation == "True":
|
||||
settings_factory_reset(interface)
|
||||
logging.info(f"Factory Reset Requested by menu")
|
||||
break
|
||||
continue
|
||||
elif selected_option == "App Settings":
|
||||
menu_win.clear()
|
||||
menu_win.refresh()
|
||||
json_editor(stdscr) # Open the App Settings menu
|
||||
continue
|
||||
# need_redraw = True
|
||||
|
||||
|
||||
field_info = current_menu.get(selected_option)
|
||||
if isinstance(field_info, tuple):
|
||||
field, current_value = field_info
|
||||
|
||||
if selected_option in ['longName', 'shortName', 'isLicensed']:
|
||||
if selected_option in ['longName', 'shortName']:
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
current_menu[selected_option] = (field, new_value)
|
||||
|
||||
elif selected_option == 'isLicensed':
|
||||
new_value = get_bool_selection(f"Current value for {selected_option}: {current_value}", str(current_value))
|
||||
new_value = new_value == "True"
|
||||
current_menu[selected_option] = (field, new_value)
|
||||
|
||||
for option, (field, value) in current_menu.items():
|
||||
modified_settings[option] = value
|
||||
|
||||
elif selected_option in ['latitude', 'longitude', 'altitude']:
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
current_menu[selected_option] = (field, new_value)
|
||||
|
||||
for option in ['latitude', 'longitude', 'altitude']:
|
||||
if option in current_menu:
|
||||
modified_settings[option] = current_menu[option][1]
|
||||
|
||||
elif field.type == 8: # Handle boolean type
|
||||
new_value = get_bool_selection(selected_option, str(current_value))
|
||||
new_value = new_value == "True" or new_value is True
|
||||
|
||||
elif field.label == field.LABEL_REPEATED: # Handle repeated field
|
||||
new_value = get_repeated_input(current_value)
|
||||
new_value = current_value if new_value is None else [int(item) for item in new_value]
|
||||
|
||||
elif field.enum_type: # Enum field
|
||||
enum_options = {v.name: v.number for v in field.enum_type.values}
|
||||
new_value_name = get_enum_input(list(enum_options.keys()), current_value)
|
||||
new_value = enum_options.get(new_value_name, current_value)
|
||||
|
||||
elif field.type == 7: # Field type 7 corresponds to FIXED32
|
||||
new_value = get_fixed32_input(current_value)
|
||||
|
||||
elif field.type == 13: # Field type 13 corresponds to UINT32
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else int(new_value)
|
||||
|
||||
elif field.type == 2: # Field type 13 corresponds to INT64
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else float(new_value)
|
||||
|
||||
else: # Handle other field types
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
|
||||
for key in menu_path[3:]: # Skip "Main Menu"
|
||||
modified_settings = modified_settings.setdefault(key, {})
|
||||
|
||||
# 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
|
||||
|
||||
current_menu[selected_option] = (field, new_value)
|
||||
else:
|
||||
current_menu = current_menu[selected_option]
|
||||
menu_path.append(selected_option)
|
||||
menu_index.append(selected_index)
|
||||
selected_index = 0
|
||||
|
||||
elif key == curses.KEY_LEFT:
|
||||
need_redraw = True
|
||||
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
|
||||
if len(menu_path) < 2:
|
||||
modified_settings.clear()
|
||||
|
||||
# Navigate back to the previous menu
|
||||
if len(menu_path) > 1:
|
||||
menu_path.pop()
|
||||
current_menu = menu["Main Menu"]
|
||||
for step in menu_path[1:]:
|
||||
current_menu = current_menu.get(step, {})
|
||||
selected_index = menu_index.pop()
|
||||
|
||||
elif key == 27: # Escape key
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
break
|
||||
|
||||
|
||||
def main(stdscr):
|
||||
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
|
||||
filename="settings.log",
|
||||
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
setup_colors()
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
globals.interface = initialize_interface(args)
|
||||
|
||||
settings_menu(stdscr, globals.interface)
|
||||
|
||||
if __name__ == "__main__":
|
||||
curses.wrapper(main)
|
||||
630
ui/curses_ui.py
630
ui/curses_ui.py
@@ -1,630 +0,0 @@
|
||||
import curses
|
||||
import textwrap
|
||||
from utilities.utils import get_channels, get_time_ago
|
||||
from settings import settings_menu
|
||||
from message_handlers.tx_handler import send_message, send_traceroute
|
||||
from ui.colors import setup_colors, get_color
|
||||
from db_handler import get_name_from_database
|
||||
import default_config as config
|
||||
import ui.dialog
|
||||
import globals
|
||||
|
||||
def draw_node_details():
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
|
||||
node = None
|
||||
for node in nodes_snapshot:
|
||||
if globals.node_list[globals.selected_node] == node['num']:
|
||||
break
|
||||
|
||||
function_win.erase()
|
||||
function_win.box()
|
||||
|
||||
nodestr = ""
|
||||
width = function_win.getmaxyx()[1]
|
||||
node_details_list = [f"{node['user']['longName']}"
|
||||
if 'user' in node and 'longName' in node['user'] else "",
|
||||
f"({node['user']['shortName']})"
|
||||
if 'user' in node and 'shortName' in node['user'] else "",
|
||||
f" | {node['user']['hwModel']}"
|
||||
if 'user' in node and 'hwModel' in node['user'] else "",
|
||||
f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "",
|
||||
f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "",
|
||||
f" | SNR: {node['snr']}dB"
|
||||
if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0)
|
||||
else "",
|
||||
]
|
||||
|
||||
for s in node_details_list:
|
||||
if len(nodestr) + len(s) < width:
|
||||
nodestr = nodestr + s
|
||||
|
||||
draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
|
||||
|
||||
def draw_function_win():
|
||||
cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit"]
|
||||
function_str = ""
|
||||
for s in cmds:
|
||||
if(len(function_str) + len(s) < function_win.getmaxyx()[1]):
|
||||
function_str += s
|
||||
|
||||
draw_centered_text_field(function_win, function_str, 0, get_color("commands"))
|
||||
|
||||
def get_msg_window_lines():
|
||||
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if globals.display_log else 0
|
||||
return messages_box.getmaxyx()[0] - 2 - packetlog_height
|
||||
|
||||
def refresh_pad(window):
|
||||
win_height = channel_box.getmaxyx()[0]
|
||||
|
||||
selected_item = globals.selected_channel
|
||||
pad = channel_pad
|
||||
box = channel_box
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
if(window == 1):
|
||||
pad = messages_pad
|
||||
box = messages_box
|
||||
lines = get_msg_window_lines()
|
||||
selected_item = globals.selected_message
|
||||
start_index = globals.selected_message
|
||||
|
||||
if globals.display_log:
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
|
||||
if(window == 2):
|
||||
pad = nodes_pad
|
||||
box = nodes_box
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
selected_item = globals.selected_node
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
|
||||
pad.refresh(start_index, 0,
|
||||
box.getbegyx()[0] + 1, box.getbegyx()[1] + 1,
|
||||
box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2)
|
||||
|
||||
def highlight_line(highlight, window, line):
|
||||
pad = channel_pad
|
||||
select_len = 0
|
||||
ch_color = get_color("channel_list")
|
||||
nd_color = get_color("node_list")
|
||||
|
||||
if(window == 2):
|
||||
pad = nodes_pad
|
||||
select_len = len(get_name_from_database(globals.node_list[line], "long"))
|
||||
|
||||
pad.chgat(line, 1, select_len, nd_color | curses.A_REVERSE if highlight else nd_color)
|
||||
|
||||
if(window == 0):
|
||||
channel = list(globals.all_messages.keys())[line]
|
||||
win_width = channel_box.getmaxyx()[1]
|
||||
|
||||
if(isinstance(channel, int)):
|
||||
channel = get_name_from_database(channel, type="long")
|
||||
select_len = min(len(channel), win_width - 4)
|
||||
|
||||
if line == globals.selected_channel and highlight == False:
|
||||
ch_color = get_color("channel_selected")
|
||||
|
||||
pad.chgat(line, 1, select_len, ch_color | curses.A_REVERSE if highlight else ch_color)
|
||||
|
||||
def add_notification(channel_number):
|
||||
globals.notifications.add(channel_number)
|
||||
|
||||
def remove_notification(channel_number):
|
||||
globals.notifications.discard(channel_number)
|
||||
|
||||
def draw_text_field(win, text, color):
|
||||
win.border()
|
||||
win.addstr(1, 1, text, color)
|
||||
|
||||
def draw_centered_text_field(win, text, y_offset, color):
|
||||
height, width = win.getmaxyx()
|
||||
x = (width - len(text)) // 2
|
||||
y = (height // 2) + y_offset
|
||||
win.addstr(y, x, text, color)
|
||||
win.refresh()
|
||||
|
||||
def draw_debug(value):
|
||||
function_win.addstr(1, 1, f"debug: {value} ")
|
||||
function_win.refresh()
|
||||
|
||||
def draw_splash(stdscr):
|
||||
setup_colors()
|
||||
curses.curs_set(0)
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.bkgd(get_color("background"))
|
||||
|
||||
height, width = stdscr.getmaxyx()
|
||||
message_1 = "/ Λ"
|
||||
message_2 = "/ / \\"
|
||||
message_3 = "P W R D"
|
||||
message_4 = "connecting..."
|
||||
|
||||
start_x = width // 2 - len(message_1) // 2
|
||||
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.attrset(get_color("window_frame"))
|
||||
stdscr.box()
|
||||
stdscr.refresh()
|
||||
curses.napms(500)
|
||||
|
||||
|
||||
def draw_channel_list():
|
||||
channel_pad.erase()
|
||||
win_height, win_width = channel_box.getmaxyx()
|
||||
start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders
|
||||
|
||||
channel_pad.resize(len(globals.all_messages), channel_box.getmaxyx()[1])
|
||||
|
||||
for i, channel in enumerate(list(globals.all_messages.keys())):
|
||||
# Convert node number to long name if it's an integer
|
||||
if isinstance(channel, int):
|
||||
channel = get_name_from_database(channel, type='long')
|
||||
|
||||
# Determine whether to add the notification
|
||||
notification = " " + config.notification_symbol if i in globals.notifications else ""
|
||||
|
||||
# Truncate the channel name if it's too long to fit in the window
|
||||
truncated_channel = channel[:win_width - 5] + '-' if len(channel) > win_width - 5 else channel
|
||||
if i == globals.selected_channel:
|
||||
if globals.current_window == 0:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list", reverse=True))
|
||||
remove_notification(globals.selected_channel)
|
||||
else:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_selected"))
|
||||
else:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list"))
|
||||
|
||||
channel_box.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame"))
|
||||
channel_box.box()
|
||||
channel_box.attrset((get_color("window_frame")))
|
||||
channel_box.refresh()
|
||||
|
||||
refresh_pad(0)
|
||||
|
||||
def draw_messages_window(scroll_to_bottom = False):
|
||||
"""Update the messages window based on the selected channel and scroll position."""
|
||||
messages_pad.erase()
|
||||
|
||||
channel = globals.channel_list[globals.selected_channel]
|
||||
|
||||
if channel in globals.all_messages:
|
||||
messages = globals.all_messages[channel]
|
||||
|
||||
msg_line_count = 0
|
||||
|
||||
row = 0
|
||||
for (prefix, message) in messages:
|
||||
full_message = f"{prefix}{message}"
|
||||
wrapped_lines = textwrap.wrap(full_message, messages_box.getmaxyx()[1] - 2)
|
||||
msg_line_count += len(wrapped_lines)
|
||||
messages_pad.resize(msg_line_count, messages_box.getmaxyx()[1])
|
||||
|
||||
for line in wrapped_lines:
|
||||
if prefix.startswith("--"):
|
||||
color = get_color("timestamps")
|
||||
elif prefix.startswith(config.sent_message_prefix):
|
||||
color = get_color("tx_messages")
|
||||
else:
|
||||
color = get_color("rx_messages")
|
||||
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
row += 1
|
||||
|
||||
messages_box.attrset(get_color("window_frame_selected") if globals.current_window == 1 else get_color("window_frame"))
|
||||
messages_box.box()
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
messages_box.refresh()
|
||||
|
||||
if(scroll_to_bottom):
|
||||
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
|
||||
else:
|
||||
globals.selected_message = max(min(globals.selected_message, msg_line_count - get_msg_window_lines()), 0)
|
||||
|
||||
refresh_pad(1)
|
||||
|
||||
draw_packetlog_win()
|
||||
|
||||
def draw_node_list():
|
||||
nodes_pad.erase()
|
||||
nodes_pad.resize(len(globals.node_list), nodes_box.getmaxyx()[1])
|
||||
|
||||
for i, node in enumerate(globals.node_list):
|
||||
if globals.selected_node == i and globals.current_window == 2:
|
||||
nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list", reverse=True))
|
||||
else:
|
||||
nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list"))
|
||||
|
||||
nodes_box.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame"))
|
||||
nodes_box.box()
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
nodes_box.refresh()
|
||||
|
||||
refresh_pad(2)
|
||||
|
||||
def select_channel(idx):
|
||||
old_selected_channel = globals.selected_channel
|
||||
globals.selected_channel = max(0, min(idx, len(globals.channel_list) - 1))
|
||||
draw_messages_window(True)
|
||||
|
||||
# For now just re-draw channel list when clearing notifications, we can probably make this more efficient
|
||||
if globals.selected_channel in globals.notifications:
|
||||
remove_notification(globals.selected_channel)
|
||||
draw_channel_list()
|
||||
return
|
||||
highlight_line(False, 0, old_selected_channel)
|
||||
highlight_line(True, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
|
||||
def scroll_channels(direction):
|
||||
new_selected_channel = globals.selected_channel + direction
|
||||
|
||||
if new_selected_channel < 0:
|
||||
new_selected_channel = len(globals.channel_list) - 1
|
||||
elif new_selected_channel >= len(globals.channel_list):
|
||||
new_selected_channel = 0
|
||||
|
||||
select_channel(new_selected_channel)
|
||||
|
||||
def scroll_messages(direction):
|
||||
globals.selected_message += direction
|
||||
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = max(0, min(globals.selected_message, msg_line_count - get_msg_window_lines()))
|
||||
|
||||
refresh_pad(1)
|
||||
|
||||
def select_node(idx):
|
||||
old_selected_node = globals.selected_node
|
||||
globals.selected_node = max(0, min(idx, len(globals.node_list) - 1))
|
||||
|
||||
highlight_line(False, 2, old_selected_node)
|
||||
highlight_line(True, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
draw_node_details()
|
||||
|
||||
def scroll_nodes(direction):
|
||||
new_selected_node = globals.selected_node + direction
|
||||
|
||||
if new_selected_node < 0:
|
||||
new_selected_node = len(globals.node_list) - 1
|
||||
elif new_selected_node >= len(globals.node_list):
|
||||
new_selected_node = 0
|
||||
|
||||
select_node(new_selected_node)
|
||||
|
||||
def draw_packetlog_win():
|
||||
|
||||
columns = [10,10,15,30]
|
||||
span = 0
|
||||
|
||||
if globals.display_log:
|
||||
packetlog_win.erase()
|
||||
height, width = packetlog_win.getmaxyx()
|
||||
|
||||
for column in columns[:-1]:
|
||||
span += column
|
||||
|
||||
# Add headers
|
||||
headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}"
|
||||
packetlog_win.addstr(1, 1, headers[:width - 2],get_color("log_header", underline=True)) # Truncate headers if they exceed window width
|
||||
|
||||
for i, packet in enumerate(reversed(globals.packet_buffer)):
|
||||
if i >= height - 3: # Skip if exceeds the window height
|
||||
break
|
||||
|
||||
# Format each field
|
||||
from_id = get_name_from_database(packet['from'], 'short').ljust(columns[0])
|
||||
to_id = (
|
||||
"BROADCAST".ljust(columns[1]) if str(packet['to']) == "4294967295"
|
||||
else get_name_from_database(packet['to'], 'short').ljust(columns[1])
|
||||
)
|
||||
if 'decoded' in packet:
|
||||
port = packet['decoded']['portnum'].ljust(columns[2])
|
||||
payload = (packet['decoded']['payload']).ljust(columns[3])
|
||||
else:
|
||||
port = "NO KEY".ljust(columns[2])
|
||||
payload = "NO KEY".ljust(columns[3])
|
||||
|
||||
# Combine and truncate if necessary
|
||||
logString = f"{from_id} {to_id} {port} {payload}"
|
||||
logString = logString[:width - 3]
|
||||
|
||||
# Add to the window
|
||||
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
|
||||
|
||||
packetlog_win.attrset(get_color("window_frame"))
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
|
||||
def handle_resize(stdscr, firstrun):
|
||||
global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win
|
||||
|
||||
# Calculate window max dimensions
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Define window dimensions and positions
|
||||
channel_width = 3 * (width // 16)
|
||||
nodes_width = 5 * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
|
||||
if firstrun:
|
||||
entry_win = curses.newwin(3, width, 0, 0)
|
||||
channel_box = curses.newwin(height - 6, channel_width, 3, 0)
|
||||
messages_box = curses.newwin(height - 6, messages_width, 3, channel_width)
|
||||
nodes_box = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
|
||||
function_win = curses.newwin(3, width, height - 3, 0)
|
||||
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
|
||||
|
||||
# Will be resized to what we need when drawn
|
||||
messages_pad = curses.newpad(1, 1)
|
||||
nodes_pad = curses.newpad(1,1)
|
||||
channel_pad = curses.newpad(1,1)
|
||||
|
||||
entry_win.bkgd(get_color("background"))
|
||||
channel_box.bkgd(get_color("background"))
|
||||
messages_box.bkgd(get_color("background"))
|
||||
nodes_box.bkgd(get_color("background"))
|
||||
|
||||
messages_pad.bkgd(get_color("background"))
|
||||
nodes_pad.bkgd(get_color("background"))
|
||||
channel_pad.bkgd(get_color("background"))
|
||||
|
||||
function_win.bkgd(get_color("background"))
|
||||
packetlog_win.bkgd(get_color("background"))
|
||||
|
||||
channel_box.attrset(get_color("window_frame"))
|
||||
entry_win.attrset(get_color("window_frame"))
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
function_win.attrset(get_color("window_frame"))
|
||||
|
||||
else:
|
||||
entry_win.erase()
|
||||
channel_box.erase()
|
||||
messages_box.erase()
|
||||
nodes_box.erase()
|
||||
function_win.erase()
|
||||
packetlog_win.erase()
|
||||
entry_win.resize(3, width)
|
||||
channel_box.resize(height - 6, channel_width)
|
||||
messages_box.resize(height - 6, messages_width)
|
||||
messages_box.mvwin(3, channel_width)
|
||||
nodes_box.resize(height - 6, nodes_width)
|
||||
nodes_box.mvwin(3, channel_width + messages_width)
|
||||
function_win.resize(3, width)
|
||||
function_win.mvwin(height - 3, 0)
|
||||
packetlog_win.resize(int(height / 3), messages_width)
|
||||
packetlog_win.mvwin(height - int(height / 3) - 3, channel_width)
|
||||
|
||||
|
||||
channel_box.box()
|
||||
entry_win.box()
|
||||
nodes_box.box()
|
||||
messages_box.box()
|
||||
|
||||
function_win.box()
|
||||
|
||||
# Refresh all windows
|
||||
entry_win.refresh()
|
||||
channel_box.refresh()
|
||||
function_win.refresh()
|
||||
nodes_box.refresh()
|
||||
messages_box.refresh()
|
||||
|
||||
entry_win.keypad(True)
|
||||
curses.curs_set(1)
|
||||
|
||||
try:
|
||||
draw_function_win()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
draw_node_list()
|
||||
except:
|
||||
# Resize events can come faster than we can re-draw, which can cause a curses error.
|
||||
# In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later.
|
||||
pass
|
||||
|
||||
def main_ui(stdscr):
|
||||
global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win
|
||||
messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None
|
||||
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
|
||||
input_text = ""
|
||||
|
||||
handle_resize(stdscr, True)
|
||||
|
||||
while True:
|
||||
draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
|
||||
# Get user input from entry window
|
||||
char = entry_win.get_wch()
|
||||
|
||||
# draw_debug(f"Keypress: {char}")
|
||||
|
||||
if char == curses.KEY_UP:
|
||||
if globals.current_window == 0:
|
||||
scroll_channels(-1)
|
||||
elif globals.current_window == 1:
|
||||
scroll_messages(-1)
|
||||
elif globals.current_window == 2:
|
||||
scroll_nodes(-1)
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char == curses.KEY_DOWN:
|
||||
if globals.current_window == 0:
|
||||
scroll_channels(1)
|
||||
elif globals.current_window == 1:
|
||||
scroll_messages(1)
|
||||
elif globals.current_window == 2:
|
||||
scroll_nodes(1)
|
||||
|
||||
elif char == curses.KEY_HOME:
|
||||
if globals.current_window == 0:
|
||||
select_channel(0)
|
||||
elif globals.current_window == 1:
|
||||
globals.selected_message = 0
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(0)
|
||||
|
||||
elif char == curses.KEY_END:
|
||||
if globals.current_window == 0:
|
||||
select_channel(len(globals.channel_list) - 1)
|
||||
elif globals.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(len(globals.node_list) - 1)
|
||||
|
||||
elif char == curses.KEY_PPAGE:
|
||||
if globals.current_window == 0:
|
||||
select_channel(globals.selected_channel - (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us
|
||||
elif globals.current_window == 1:
|
||||
globals.selected_message = max(globals.selected_message - get_msg_window_lines(), 0)
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(globals.selected_node - (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||
|
||||
elif char == curses.KEY_NPAGE:
|
||||
if globals.current_window == 0:
|
||||
select_channel(globals.selected_channel + (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us
|
||||
elif globals.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = min(globals.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines())
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(globals.selected_node + (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||
|
||||
elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT:
|
||||
delta = -1 if char == curses.KEY_LEFT else 1
|
||||
|
||||
old_window = globals.current_window
|
||||
globals.current_window = (globals.current_window + delta) % 3
|
||||
|
||||
if old_window == 0:
|
||||
channel_box.attrset(get_color("window_frame"))
|
||||
channel_box.box()
|
||||
channel_box.refresh()
|
||||
highlight_line(False, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
if old_window == 1:
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
messages_box.box()
|
||||
messages_box.refresh()
|
||||
refresh_pad(1)
|
||||
elif old_window == 2:
|
||||
draw_function_win()
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
nodes_box.box()
|
||||
nodes_box.refresh()
|
||||
highlight_line(False, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
if globals.current_window == 0:
|
||||
channel_box.attrset(get_color("window_frame_selected"))
|
||||
channel_box.box()
|
||||
channel_box.attrset(get_color("window_frame"))
|
||||
channel_box.refresh()
|
||||
highlight_line(True, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
elif globals.current_window == 1:
|
||||
messages_box.attrset(get_color("window_frame_selected"))
|
||||
messages_box.box()
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
messages_box.refresh()
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
draw_node_details()
|
||||
nodes_box.attrset(get_color("window_frame_selected"))
|
||||
nodes_box.box()
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
nodes_box.refresh()
|
||||
highlight_line(True, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
# Check for Esc
|
||||
elif char == chr(27):
|
||||
break
|
||||
|
||||
# Check for Ctrl + t
|
||||
elif char == chr(20):
|
||||
send_traceroute()
|
||||
curses.curs_set(0) # Hide cursor
|
||||
ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.")
|
||||
curses.curs_set(1) # Show cursor again
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
if globals.current_window == 2:
|
||||
node_list = globals.node_list
|
||||
if node_list[globals.selected_node] not in globals.channel_list:
|
||||
globals.channel_list.append(node_list[globals.selected_node])
|
||||
globals.all_messages[node_list[globals.selected_node]] = []
|
||||
|
||||
globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node])
|
||||
globals.selected_node = 0
|
||||
globals.current_window = 0
|
||||
|
||||
draw_node_list()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
|
||||
elif len(input_text) > 0:
|
||||
# Enter key pressed, send user input as message
|
||||
send_message(input_text, channel=globals.selected_channel)
|
||||
draw_messages_window(True)
|
||||
|
||||
# Clear entry window and reset input text
|
||||
input_text = ""
|
||||
entry_win.erase()
|
||||
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
||||
if input_text:
|
||||
input_text = input_text[:-1]
|
||||
y, x = entry_win.getyx()
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.addch(' ') #
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.refresh()
|
||||
|
||||
elif char == "`": # ` Launch the settings interface
|
||||
curses.curs_set(0)
|
||||
settings_menu(stdscr, globals.interface)
|
||||
curses.curs_set(1)
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char == chr(16):
|
||||
# Display packet log
|
||||
if globals.display_log is False:
|
||||
globals.display_log = True
|
||||
draw_messages_window(True)
|
||||
else:
|
||||
globals.display_log = False
|
||||
packetlog_win.erase()
|
||||
draw_messages_window(True)
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if(isinstance(char, str)):
|
||||
input_text += char
|
||||
else:
|
||||
input_text += chr(char)
|
||||
|
||||
|
||||
318
user_config.py
318
user_config.py
@@ -1,318 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import curses
|
||||
from ui.colors import get_color, setup_colors, COLOR_MAP
|
||||
from default_config import format_json_single_line_arrays, loaded_config
|
||||
from input_handlers import select_from_list
|
||||
|
||||
width = 60
|
||||
save_option_text = "Save Changes"
|
||||
|
||||
def edit_color_pair(key, current_value):
|
||||
|
||||
"""
|
||||
Allows the user to select a foreground and background color for a key.
|
||||
"""
|
||||
color_list = [" "] + list(COLOR_MAP.keys())
|
||||
fg_color = select_from_list(f"Select Foreground Color for {key}", current_value[0], color_list)
|
||||
bg_color = select_from_list(f"Select Background Color for {key}", current_value[1], color_list)
|
||||
|
||||
return [fg_color, bg_color]
|
||||
|
||||
def edit_value(key, current_value):
|
||||
width = 60
|
||||
height = 10
|
||||
input_width = width - 16 # Allow space for "New Value: "
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create a centered window
|
||||
edit_win = curses.newwin(height, width, start_y, start_x)
|
||||
edit_win.bkgd(get_color("background"))
|
||||
edit_win.attrset(get_color("window_frame"))
|
||||
edit_win.border()
|
||||
|
||||
# Display instructions
|
||||
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
|
||||
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
|
||||
|
||||
wrap_width = width - 4 # Account for border and padding
|
||||
wrapped_lines = [current_value[i:i+wrap_width] for i in range(0, len(current_value), wrap_width)]
|
||||
|
||||
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
|
||||
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
|
||||
|
||||
edit_win.refresh()
|
||||
|
||||
# Handle theme selection dynamically
|
||||
if key == "theme":
|
||||
# Load theme names dynamically from the JSON
|
||||
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
|
||||
return select_from_list("Select Theme", current_value, theme_options)
|
||||
|
||||
# Standard Input Mode (Scrollable)
|
||||
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
||||
curses.curs_set(1)
|
||||
|
||||
scroll_offset = 0 # Determines which part of the text is visible
|
||||
user_input = ""
|
||||
input_position = (7, 13) # Tuple for row and column
|
||||
row, col = input_position # Unpack tuple
|
||||
while True:
|
||||
visible_text = user_input[scroll_offset:scroll_offset + input_width] # Only show what fits
|
||||
edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
|
||||
edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
|
||||
edit_win.refresh()
|
||||
|
||||
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
|
||||
key = edit_win.get_wch()
|
||||
|
||||
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
|
||||
curses.curs_set(0)
|
||||
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)): # 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 # 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: # Scroll if input exceeds visible area
|
||||
scroll_offset += 1
|
||||
|
||||
curses.curs_set(0)
|
||||
return user_input if user_input else current_value
|
||||
|
||||
|
||||
def render_menu(current_data, menu_path, selected_index):
|
||||
"""
|
||||
Render the configuration menu with a Save button directly added to the window.
|
||||
"""
|
||||
# Determine menu items based on the type of current_data
|
||||
if isinstance(current_data, dict):
|
||||
options = list(current_data.keys())
|
||||
elif isinstance(current_data, list):
|
||||
options = [f"[{i}]" for i in range(len(current_data))]
|
||||
else:
|
||||
options = [] # Fallback in case of unexpected data types
|
||||
|
||||
# Calculate dynamic dimensions for the menu
|
||||
num_items = len(options)
|
||||
height = min(curses.LINES - 2, num_items + 6) # Include space for borders and Save button
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
# Create the window
|
||||
menu_win = curses.newwin(height, width, start_y, start_x)
|
||||
menu_win.clear()
|
||||
menu_win.bkgd(get_color("background"))
|
||||
menu_win.attrset(get_color("window_frame"))
|
||||
menu_win.border()
|
||||
menu_win.keypad(True)
|
||||
|
||||
# Display the menu path
|
||||
header = " > ".join(menu_path)
|
||||
if len(header) > width - 4:
|
||||
header = header[:width - 7] + "..."
|
||||
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
|
||||
|
||||
# Create the pad for scrolling
|
||||
menu_pad = curses.newpad(num_items + 1, width - 8)
|
||||
menu_pad.bkgd(get_color("background"))
|
||||
|
||||
# Populate the pad with menu options
|
||||
for idx, key in enumerate(options):
|
||||
value = current_data[key] if isinstance(current_data, dict) else current_data[int(key.strip("[]"))]
|
||||
display_key = f"{key}"[:width // 2 - 2]
|
||||
display_value = (
|
||||
f"{value}"[:width // 2 - 8]
|
||||
)
|
||||
|
||||
color = get_color("settings_default", reverse=(idx == 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
|
||||
save_button_position = height - 2
|
||||
menu_win.addstr(
|
||||
save_button_position,
|
||||
(width - len(save_option_text)) // 2,
|
||||
save_option_text,
|
||||
get_color("settings_save", reverse=(selected_index == len(options))),
|
||||
)
|
||||
|
||||
# Refresh menu and pad
|
||||
menu_win.refresh()
|
||||
menu_pad.refresh(
|
||||
0,
|
||||
0,
|
||||
menu_win.getbegyx()[0] + 3,
|
||||
menu_win.getbegyx()[1] + 4,
|
||||
|
||||
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
|
||||
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
|
||||
)
|
||||
|
||||
return menu_win, menu_pad, options
|
||||
|
||||
|
||||
def move_highlight(old_idx, new_idx, options, menu_win, menu_pad):
|
||||
if old_idx == new_idx:
|
||||
return # no-op
|
||||
|
||||
show_save_option = True
|
||||
|
||||
max_index = len(options) + (1 if show_save_option else 0) - 1
|
||||
|
||||
if show_save_option and old_idx == max_index: # special case un-highlight "Save" option
|
||||
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option_text)) // 2, len(save_option_text), get_color("settings_save"))
|
||||
else:
|
||||
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_default"))
|
||||
|
||||
if show_save_option and new_idx == max_index: # special case highlight "Save" option
|
||||
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option_text)) // 2, len(save_option_text), get_color("settings_save", reverse = True))
|
||||
else:
|
||||
menu_pad.chgat(new_idx, 0,menu_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
|
||||
|
||||
start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 6))
|
||||
|
||||
menu_win.refresh()
|
||||
menu_pad.refresh(start_index, 0,
|
||||
menu_win.getbegyx()[0] + 3,
|
||||
menu_win.getbegyx()[1] + 4,
|
||||
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
|
||||
menu_win.getbegyx()[1] + 4 + menu_win.getmaxyx()[1] - 4)
|
||||
|
||||
|
||||
def json_editor(stdscr):
|
||||
menu_path = ["App Settings"]
|
||||
selected_index = 0 # Track the selected option
|
||||
|
||||
file_path = "config.json"
|
||||
show_save_option = True # Always show the Save button
|
||||
|
||||
# Ensure the file exists
|
||||
if not os.path.exists(file_path):
|
||||
with open(file_path, "w") as f:
|
||||
json.dump({}, f)
|
||||
|
||||
# Load JSON data
|
||||
with open(file_path, "r") as f:
|
||||
original_data = json.load(f)
|
||||
|
||||
data = original_data # Reference to the original data
|
||||
current_data = data # Track the current level of the menu
|
||||
|
||||
# Render the menu
|
||||
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
|
||||
need_redraw = True
|
||||
|
||||
while True:
|
||||
if(need_redraw):
|
||||
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
|
||||
menu_win.refresh()
|
||||
need_redraw = False
|
||||
|
||||
max_index = len(options) + (1 if show_save_option else 0) - 1
|
||||
key = menu_win.getch()
|
||||
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
|
||||
old_selected_index = selected_index
|
||||
selected_index = max_index if selected_index == 0 else selected_index - 1
|
||||
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
|
||||
old_selected_index = selected_index
|
||||
selected_index = 0 if selected_index == max_index else selected_index + 1
|
||||
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
|
||||
|
||||
elif key == ord("\t") and show_save_option:
|
||||
old_selected_index = selected_index
|
||||
selected_index = max_index
|
||||
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
|
||||
|
||||
elif key in (curses.KEY_RIGHT, ord("\n")):
|
||||
|
||||
need_redraw = True
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
|
||||
|
||||
if selected_index < len(options): # Handle selection of a menu item
|
||||
selected_key = options[selected_index]
|
||||
|
||||
# Handle nested data
|
||||
if isinstance(current_data, dict):
|
||||
if selected_key in current_data:
|
||||
selected_data = current_data[selected_key]
|
||||
else:
|
||||
continue # Skip invalid key
|
||||
elif isinstance(current_data, list):
|
||||
selected_data = current_data[int(selected_key.strip("[]"))]
|
||||
|
||||
if isinstance(selected_data, list) and len(selected_data) == 2:
|
||||
# Edit color pair
|
||||
new_value = edit_color_pair(
|
||||
selected_key, selected_data)
|
||||
current_data[selected_key] = new_value
|
||||
|
||||
elif isinstance(selected_data, (dict, list)):
|
||||
# Navigate into nested data
|
||||
menu_path.append(str(selected_key))
|
||||
current_data = selected_data
|
||||
selected_index = 0 # Reset the selected index
|
||||
|
||||
else:
|
||||
# General value editing
|
||||
new_value = edit_value(selected_key, selected_data)
|
||||
current_data[selected_key] = new_value
|
||||
need_redraw = True
|
||||
|
||||
else:
|
||||
# Save button selected
|
||||
save_json(file_path, data)
|
||||
stdscr.refresh()
|
||||
continue
|
||||
|
||||
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
|
||||
|
||||
need_redraw = True
|
||||
menu_win.erase()
|
||||
menu_win.refresh()
|
||||
|
||||
# Navigate back in the menu
|
||||
if len(menu_path) > 1:
|
||||
menu_path.pop()
|
||||
current_data = data
|
||||
for path in menu_path[1:]:
|
||||
current_data = current_data[path] if isinstance(current_data, dict) else current_data[int(path.strip("[]"))]
|
||||
selected_index = 0
|
||||
else:
|
||||
# Exit the editor
|
||||
menu_win.clear()
|
||||
menu_win.refresh()
|
||||
break
|
||||
|
||||
|
||||
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.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
setup_colors()
|
||||
json_editor(stdscr)
|
||||
|
||||
if __name__ == "__main__":
|
||||
curses.wrapper(main)
|
||||
@@ -1,104 +0,0 @@
|
||||
import globals
|
||||
from datetime import datetime
|
||||
from meshtastic.protobuf import config_pb2
|
||||
|
||||
def get_channels():
|
||||
"""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
|
||||
# globals.channel_list = []
|
||||
|
||||
for device_channel in device_channels:
|
||||
if device_channel.role:
|
||||
# Use the channel name if available, otherwise use the modem preset
|
||||
if device_channel.settings.name:
|
||||
channel_name = device_channel.settings.name
|
||||
else:
|
||||
# If channel name is blank, use the modem preset
|
||||
lora_config = node.localConfig.lora
|
||||
modem_preset_enum = lora_config.modem_preset
|
||||
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
|
||||
channel_name = convert_to_camel_case(modem_preset_string)
|
||||
|
||||
# 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 globals.all_messages[channel_name] if it doesn't exist
|
||||
if channel_name not in globals.all_messages:
|
||||
globals.all_messages[channel_name] = []
|
||||
|
||||
|
||||
return globals.channel_list
|
||||
|
||||
def get_node_list():
|
||||
if globals.interface.nodes:
|
||||
sorted_nodes = sorted(
|
||||
globals.interface.nodes.values(),
|
||||
key = lambda node: (node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0),
|
||||
reverse = True)
|
||||
return [node['num'] for node in sorted_nodes]
|
||||
return []
|
||||
|
||||
def get_nodeNum():
|
||||
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)
|
||||
return camel_case_string
|
||||
|
||||
def get_name_from_number(number, type='long'):
|
||||
name = ""
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
|
||||
for node in nodes_snapshot:
|
||||
if number == node['num']:
|
||||
if type == 'long':
|
||||
return node['user']['longName']
|
||||
elif type == 'short':
|
||||
return node['user']['shortName']
|
||||
else:
|
||||
pass
|
||||
# If no match is found, use the ID as a string
|
||||
return str(decimal_to_hex(number))
|
||||
|
||||
def get_time_ago(timestamp):
|
||||
now = datetime.now()
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
delta = now - dt
|
||||
|
||||
value = 0
|
||||
unit = ""
|
||||
|
||||
if delta.days > 365:
|
||||
value = delta.days // 365
|
||||
unit = "y"
|
||||
elif delta.days > 30:
|
||||
value = delta.days // 30
|
||||
unit = "mon"
|
||||
elif delta.days > 7:
|
||||
value = delta.days // 7
|
||||
unit = "w"
|
||||
elif delta.days > 0:
|
||||
value = delta.days
|
||||
unit = "d"
|
||||
elif delta.seconds > 3600:
|
||||
value = delta.seconds // 3600
|
||||
unit = "h"
|
||||
elif delta.seconds > 60:
|
||||
value = delta.seconds // 60
|
||||
unit = "min"
|
||||
|
||||
if len(unit) > 0:
|
||||
return f"{value} {unit} ago"
|
||||
|
||||
return "now"
|
||||
|
||||
Reference in New Issue
Block a user