mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
243 Commits
v1.0.0
...
reinitiali
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088d13c88b | ||
|
|
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 | ||
|
|
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 | ||
|
|
e273b3325d | ||
|
|
ad7c7a148f | ||
|
|
df7d9b0e2e | ||
|
|
b9d8c9ad44 | ||
|
|
e27504f215 | ||
|
|
648993607d | ||
|
|
cf8ee248de | ||
|
|
03f7fd81a7 | ||
|
|
5730beafa9 | ||
|
|
2a6a1ff798 | ||
|
|
09d832a203 | ||
|
|
bea051a69f | ||
|
|
aa1b7d43a8 | ||
|
|
59187a3838 | ||
|
|
47d6212b3a | ||
|
|
f6e7a09c7e | ||
|
|
1d9d055a4d | ||
|
|
dae8c46b7b | ||
|
|
d7a9112918 | ||
|
|
5084eca388 | ||
|
|
5e4b28d47a | ||
|
|
c8a5ad3a95 | ||
|
|
852a912072 | ||
|
|
af5fe53658 | ||
|
|
f21269ba62 | ||
|
|
3f94b9e276 | ||
|
|
c865d6a942 | ||
|
|
0bbabba77b | ||
|
|
bf43799a7d | ||
|
|
dd0ce4f098 | ||
|
|
5bd9b45753 | ||
|
|
4aaef5381e | ||
|
|
51dcfb5aa2 | ||
|
|
77b995f00f | ||
|
|
62cc2089db | ||
|
|
2eb8a17094 | ||
|
|
abe400648f | ||
|
|
22b2a9a50e | ||
|
|
9a306f1553 | ||
|
|
92db3f4a30 | ||
|
|
a32526e650 | ||
|
|
1ebf1c4988 | ||
|
|
7901f00c49 | ||
|
|
4ce279ab0d | ||
|
|
e8e91f893e | ||
|
|
16c81f059d | ||
|
|
5588c6c6d9 | ||
|
|
73111a46bb | ||
|
|
2d762515b4 | ||
|
|
6ce9707232 | ||
|
|
c33b903825 | ||
|
|
c5327d8644 | ||
|
|
ed1e9a3055 | ||
|
|
f0554ec1f6 | ||
|
|
bb623d149c | ||
|
|
4a92ad49ce | ||
|
|
9d0470d55b | ||
|
|
e96ea7ffef | ||
|
|
44b1a3071b | ||
|
|
702d20a011 | ||
|
|
fba4642ff8 | ||
|
|
916b0cfe53 | ||
|
|
e086814b83 | ||
|
|
92f08d020e | ||
|
|
86463f6f84 | ||
|
|
e58340fa65 | ||
|
|
c243daf253 | ||
|
|
70f1f5d4bf | ||
|
|
67f1bde217 | ||
|
|
ca17bbee31 | ||
|
|
659dad493c | ||
|
|
4359d37979 | ||
|
|
96612b3e1b | ||
|
|
84246aefd9 | ||
|
|
7cd98a39f8 | ||
|
|
978e2942cb | ||
|
|
92790ddca6 | ||
|
|
d5a6a0462f | ||
|
|
3816a3f166 | ||
|
|
c61dc19319 | ||
|
|
91d331af4f | ||
|
|
be92ac5de3 | ||
|
|
10179c4179 | ||
|
|
6e45caaac2 | ||
|
|
152555156e | ||
|
|
056b8b5f5f | ||
|
|
07f0721fd5 | ||
|
|
cf9276d399 | ||
|
|
482d158b15 | ||
|
|
11bd9c75ed | ||
|
|
1b2701e1a1 | ||
|
|
7fcc9abd76 | ||
|
|
9bfddb954d | ||
|
|
deb231fc63 | ||
|
|
bbe9e66fa5 | ||
|
|
aa7d98b1b0 | ||
|
|
8895784503 | ||
|
|
695b4949c0 | ||
|
|
404bac9133 | ||
|
|
4698b81a3f | ||
|
|
e7e9f24fe2 | ||
|
|
01f67ea8b5 | ||
|
|
c19684c119 | ||
|
|
0b0d8c482b | ||
|
|
f9774b2248 | ||
|
|
d6db1e1832 | ||
|
|
4c85aaecdf | ||
|
|
d8fc02b28a | ||
|
|
039673bb18 | ||
|
|
d81e694ee6 | ||
|
|
dfea291d21 | ||
|
|
41c60a49e9 | ||
|
|
fb3138883f | ||
|
|
767f0e2288 | ||
|
|
43680f8afb | ||
|
|
e15f625716 | ||
|
|
c090b3dd58 | ||
|
|
f472a3040c | ||
|
|
4a0c49b7d6 | ||
|
|
3034a1464a | ||
|
|
80fe10c050 | ||
|
|
0962c5b284 | ||
|
|
1b3abdebf2 | ||
|
|
a710374fe9 | ||
|
|
cb088c51d4 | ||
|
|
ccb46b8553 | ||
|
|
35748d071e | ||
|
|
7e85085b98 | ||
|
|
7493d21c1a | ||
|
|
d0af0e6af1 | ||
|
|
796c40b560 | ||
|
|
1c0704b940 | ||
|
|
6384777bb6 | ||
|
|
2fbaee5fc5 | ||
|
|
06e71331b6 | ||
|
|
fea705a09f | ||
|
|
a47a4a9b32 | ||
|
|
bc72c3b0b6 | ||
|
|
559618a229 | ||
|
|
2abdd763c1 | ||
|
|
89d8b7690f | ||
|
|
d267f41805 | ||
|
|
72939e5988 | ||
|
|
bd5d8aa6e4 | ||
|
|
5899f35833 | ||
|
|
550556df2b | ||
|
|
fac22aee91 | ||
|
|
87e68689f4 | ||
|
|
fe1eaacee9 | ||
|
|
94e7e8f628 | ||
|
|
54ec4009a1 | ||
|
|
9901e5b8a0 | ||
|
|
80a5edb6de | ||
|
|
93664397e8 | ||
|
|
5bd33ed786 | ||
|
|
fae3330bb0 | ||
|
|
5bd91bde25 | ||
|
|
2a7317e612 | ||
|
|
68f13585b6 | ||
|
|
ad81d34551 | ||
|
|
4c4c0d553e | ||
|
|
02c104dcd5 | ||
|
|
96493e5973 | ||
|
|
8a13b60d23 | ||
|
|
7ae4bb7c9d | ||
|
|
36ba9065a2 | ||
|
|
2ad2aa1faa | ||
|
|
342afce9fb | ||
|
|
205a0c547d | ||
|
|
daa94f57a6 | ||
|
|
5e17e8e7d3 | ||
|
|
455c3b10dd | ||
|
|
0701111f2d | ||
|
|
62a847a565 | ||
|
|
457e1a66fe |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,4 +1,10 @@
|
||||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
node-configs/
|
||||
client.db
|
||||
.DS_Store
|
||||
client.log
|
||||
settings.log
|
||||
config.json
|
||||
default_config.log
|
||||
43
README.md
43
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 `settings.py`
|
||||
|
||||
<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,7 +46,7 @@ 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.
|
||||
|
||||
If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost.
|
||||
@@ -29,3 +57,8 @@ If no connection arguments are specified, the client will attempt a serial conne
|
||||
python main.py --port /dev/ttyUSB0
|
||||
python main.py --host 192.168.1.1
|
||||
python main.py --ble BlAddressOfDevice
|
||||
```
|
||||
To quickly connect to localhost, use:
|
||||
```sh
|
||||
python main.py -t
|
||||
```
|
||||
|
||||
412
db_handler.py
412
db_handler.py
@@ -1,229 +1,329 @@
|
||||
import sqlite3
|
||||
import globals
|
||||
import time
|
||||
from utilities.utils import get_nodeNum, get_name_from_number
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from utilities.utils import decimal_to_hex
|
||||
import default_config as config
|
||||
import globals
|
||||
|
||||
def get_table_name(channel):
|
||||
# Construct the table name
|
||||
table_name = f"{str(globals.myNodeNum)}_{channel}_messages"
|
||||
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
|
||||
return quoted_table_name
|
||||
|
||||
|
||||
def save_message_to_db(channel, user_id, message_text):
|
||||
"""Save messages to the database, ensuring the table exists."""
|
||||
try:
|
||||
with sqlite3.connect(globals.db_file_path) as db_connection:
|
||||
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()
|
||||
|
||||
# Construct the table name
|
||||
table_name = f"{str(get_nodeNum())}_{channel}_messages"
|
||||
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
|
||||
|
||||
# Ensure the table exists
|
||||
create_table_query = f'''
|
||||
CREATE TABLE IF NOT EXISTS {quoted_table_name} (
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER
|
||||
)
|
||||
'''
|
||||
db_cursor.execute(create_table_query)
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Insert the message
|
||||
insert_query = f'''
|
||||
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
'''
|
||||
db_cursor.execute(insert_query, (user_id, message_text, int(time.time())))
|
||||
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
|
||||
db_connection.commit()
|
||||
|
||||
return timestamp
|
||||
|
||||
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:
|
||||
db_cursor = db_connection.cursor()
|
||||
update_query = f"""
|
||||
UPDATE {get_table_name(channel)}
|
||||
SET ack_type = ?
|
||||
WHERE user_id = ? AND
|
||||
timestamp = ? AND
|
||||
message_text = ?
|
||||
"""
|
||||
|
||||
db_cursor.execute(update_query, (ack, str(globals.myNodeNum), timestamp, message))
|
||||
db_connection.commit()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"SQLite error in save_message_to_db: {e}")
|
||||
logging.error(f"SQLite error in update_ack_nak: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Unexpected error in save_message_to_db: {e}")
|
||||
logging.error(f"Unexpected error in update_ack_nak: {e}")
|
||||
|
||||
|
||||
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(globals.db_file_path) as db_connection:
|
||||
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(get_nodeNum())}_%_messages",))
|
||||
db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
|
||||
tables = [row[0] for row in db_cursor.fetchall()]
|
||||
|
||||
# Iterate through each table and fetch its messages
|
||||
for table_name in tables:
|
||||
query = f'SELECT user_id, message_text FROM "{table_name}"'
|
||||
quoted_table_name = f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
|
||||
table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({quoted_table_name})')]
|
||||
if "ack_type" not in table_columns:
|
||||
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
|
||||
db_cursor.execute(update_table_query)
|
||||
|
||||
query = f'SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}'
|
||||
|
||||
try:
|
||||
# Fetch all messages from the table
|
||||
db_cursor.execute(query)
|
||||
db_messages = [(row[0], row[1]) for row in db_cursor.fetchall()] # Save as tuples
|
||||
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
|
||||
|
||||
# Extract the channel name from the table name
|
||||
channel = table_name.split("_")[1]
|
||||
|
||||
# Convert the channel to an integer if it's numeric, otherwise keep it as a string
|
||||
# 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
|
||||
if channel not in globals.all_messages:
|
||||
globals.all_messages[channel] = []
|
||||
|
||||
# Add messages to globals.all_messages in tuple format
|
||||
for user_id, message in db_messages:
|
||||
if user_id == str(get_nodeNum()):
|
||||
formatted_message = (f"{globals.sent_message_prefix}: ", message)
|
||||
else:
|
||||
formatted_message = (f"{globals.message_prefix} {get_name_from_number(int(user_id), 'short')}: ", message)
|
||||
|
||||
if formatted_message not in globals.all_messages[channel]:
|
||||
globals.all_messages[channel].append(formatted_message)
|
||||
# Add messages to globals.all_messages grouped by hourly timestamp
|
||||
hourly_messages = {}
|
||||
for user_id, message, timestamp, ack_type in db_messages:
|
||||
hour = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:00')
|
||||
if hour not in hourly_messages:
|
||||
hourly_messages[hour] = []
|
||||
|
||||
ack_str = config.ack_unknown_str
|
||||
if ack_type == "Implicit":
|
||||
ack_str = config.ack_implicit_str
|
||||
elif ack_type == "Ack":
|
||||
ack_str = config.ack_str
|
||||
elif ack_type == "Nak":
|
||||
ack_str = config.nak_str
|
||||
|
||||
if user_id == str(globals.myNodeNum):
|
||||
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
|
||||
else:
|
||||
formatted_message = (f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ", message)
|
||||
|
||||
hourly_messages[hour].append(formatted_message)
|
||||
|
||||
# Flatten the hourly messages into globals.all_messages[channel]
|
||||
for hour, messages in sorted(hourly_messages.items()):
|
||||
globals.all_messages[channel].append((f"-- {hour} --", ""))
|
||||
globals.all_messages[channel].extend(messages)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"SQLite error while loading messages from table '{table_name}': {e}")
|
||||
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"SQLite error in load_messages_from_db: {e}")
|
||||
logging.error(f"SQLite error in load_messages_from_db: {e}")
|
||||
|
||||
|
||||
def init_nodedb():
|
||||
"""Initialize the node database and update it with nodes from the interface."""
|
||||
|
||||
try:
|
||||
with sqlite3.connect(globals.db_file_path) as db_connection:
|
||||
if not globals.interface.nodes:
|
||||
return # No nodes to initialize
|
||||
|
||||
ensure_node_table_exists() # Ensure the table exists before insertion
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
|
||||
# Insert or update all nodes
|
||||
for node in nodes_snapshot:
|
||||
update_node_info_in_db(
|
||||
user_id=node['num'],
|
||||
long_name=node['user'].get('longName', ''),
|
||||
short_name=node['user'].get('shortName', ''),
|
||||
hw_model=node['user'].get('hwModel', ''),
|
||||
is_licensed=node['user'].get('isLicensed', '0'),
|
||||
role=node['user'].get('role', 'CLIENT'),
|
||||
public_key=node['user'].get('publicKey', '')
|
||||
)
|
||||
|
||||
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, updating if necessary."""
|
||||
try:
|
||||
user_id = packet['from']
|
||||
long_name = packet['decoded']['user']['longName']
|
||||
short_name = packet['decoded']['user']['shortName']
|
||||
hw_model = packet['decoded']['user']['hwModel']
|
||||
is_licensed = packet['decoded']['user'].get('isLicensed', '0')
|
||||
role = packet['decoded']['user'].get('role', 'CLIENT')
|
||||
public_key = packet['decoded']['user'].get('publicKey', '')
|
||||
|
||||
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}")
|
||||
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 name construction
|
||||
table_name = f"{str(get_nodeNum())}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote the table name because it might begin with numerics
|
||||
|
||||
# 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
|
||||
)
|
||||
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
|
||||
|
||||
# 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(create_table_query)
|
||||
|
||||
# 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_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:
|
||||
print(f"SQLite error in init_nodedb: {e}")
|
||||
logging.error(f"SQLite error in update_node_info_in_db: {e}")
|
||||
except Exception as e:
|
||||
print(f"Unexpected error in init_nodedb: {e}")
|
||||
logging.error(f"Unexpected error in update_node_info_in_db: {e}")
|
||||
|
||||
def maybe_store_nodeinfo_in_db(packet):
|
||||
"""Save nodeinfo unless that record is already there."""
|
||||
|
||||
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(globals.db_file_path) as db_connection:
|
||||
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}")
|
||||
|
||||
table_name = f"{str(get_nodeNum())}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote the table name becuase we might begin with numerics
|
||||
|
||||
def get_name_from_database(user_id, type="long"):
|
||||
"""
|
||||
Retrieve a user's name (long or short) from the node database.
|
||||
|
||||
:param user_id: The user ID to look up.
|
||||
:param type: "long" for long name, "short" for short name.
|
||||
:return: The retrieved name or the hex of the user id
|
||||
"""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
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()
|
||||
# Construct table name
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote table name for safety
|
||||
|
||||
# Determine the correct column to fetch
|
||||
column_name = "long_name" if type == "long" else "short_name"
|
||||
|
||||
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
|
||||
# Query the database
|
||||
query = f"SELECT {column_name} FROM {nodeinfo_table} WHERE user_id = ?"
|
||||
db_cursor.execute(query, (user_id,))
|
||||
result = db_cursor.fetchone()
|
||||
|
||||
return result[0] if result else decimal_to_hex(user_id)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"SQLite error in maybe_store_nodeinfo_in_db: {e}")
|
||||
logging.error(f"SQLite error in get_name_from_database: {e}")
|
||||
return "Unknown"
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in get_name_from_database: {e}")
|
||||
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"
|
||||
|
||||
finally:
|
||||
db_connection.close()
|
||||
|
||||
193
default_config.py
Normal file
193
default_config.py
Normal file
@@ -0,0 +1,193 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
|
||||
def format_json_single_line_arrays(data, indent=4):
|
||||
"""
|
||||
Formats JSON with arrays on a single line while keeping other elements properly indented.
|
||||
"""
|
||||
def format_value(value, current_indent):
|
||||
if isinstance(value, dict):
|
||||
items = []
|
||||
for key, val in value.items():
|
||||
items.append(
|
||||
f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}'
|
||||
)
|
||||
return "{\n" + ",\n".join(items) + f"\n{' ' * (current_indent - indent)}}}"
|
||||
elif isinstance(value, list):
|
||||
return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]"
|
||||
else:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
return format_value(data, indent)
|
||||
|
||||
# Recursive function to check and update nested dictionaries
|
||||
def update_dict(default, actual):
|
||||
updated = False
|
||||
for key, value in default.items():
|
||||
if key not in actual:
|
||||
actual[key] = value
|
||||
updated = True
|
||||
elif isinstance(value, dict):
|
||||
# Recursively check nested dictionaries
|
||||
updated = update_dict(value, actual[key]) or updated
|
||||
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"],
|
||||
"splash_logo": ["green", "black"],
|
||||
"splash_text": ["white", "black"],
|
||||
"input": ["white", "black"],
|
||||
"node_list": ["white", "black"],
|
||||
"channel_list": ["white", "black"],
|
||||
"channel_selected": ["green", "black"],
|
||||
"rx_messages": ["cyan", "black"],
|
||||
"tx_messages": ["green", "black"],
|
||||
"timestamps": ["white", "black"],
|
||||
"commands": ["white", "black"],
|
||||
"window_frame": ["white", "black"],
|
||||
"window_frame_selected": ["green", "black"],
|
||||
"log_header": ["blue", "black"],
|
||||
"log": ["green", "black"],
|
||||
"settings_default": ["white", "black"],
|
||||
"settings_sensitive": ["red", "black"],
|
||||
"settings_save": ["green", "black"],
|
||||
"settings_breadcrumbs": ["white", "black"]
|
||||
}
|
||||
|
||||
COLOR_CONFIG_LIGHT = {
|
||||
"default": ["black", "white"],
|
||||
"background": [" ", "white"],
|
||||
"splash_logo": ["green", "white"],
|
||||
"splash_text": ["black", "white"],
|
||||
"input": ["black", "white"],
|
||||
"node_list": ["black", "white"],
|
||||
"channel_list": ["black", "white"],
|
||||
"channel_selected": ["green", "white"],
|
||||
"rx_messages": ["cyan", "white"],
|
||||
"tx_messages": ["green", "white"],
|
||||
"timestamps": ["black", "white"],
|
||||
"commands": ["black", "white"],
|
||||
"window_frame": ["black", "white"],
|
||||
"window_frame_selected": ["green", "white"],
|
||||
"log_header": ["black", "white"],
|
||||
"log": ["blue", "white"],
|
||||
"settings_default": ["black", "white"],
|
||||
"settings_sensitive": ["red", "white"],
|
||||
"settings_save": ["green", "white"],
|
||||
"settings_breadcrumbs": ["black", "white"]
|
||||
}
|
||||
COLOR_CONFIG_GREEN = {
|
||||
"default": ["green", "black"],
|
||||
"background": [" ", "black"],
|
||||
"splash_logo": ["green", "black"],
|
||||
"splash_text": ["green", "black"],
|
||||
"input": ["green", "black"],
|
||||
"node_list": ["green", "black"],
|
||||
"channel_list": ["green", "black"],
|
||||
"channel_selected": ["cyan", "black"],
|
||||
"rx_messages": ["green", "black"],
|
||||
"tx_messages": ["green", "black"],
|
||||
"timestamps": ["green", "black"],
|
||||
"commands": ["green", "black"],
|
||||
"window_frame": ["green", "black"],
|
||||
"window_frame_selected": ["cyan", "black"],
|
||||
"log_header": ["green", "black"],
|
||||
"log": ["green", "black"],
|
||||
"settings_default": ["green", "black"],
|
||||
"settings_sensitive": ["green", "black"],
|
||||
"settings_save": ["green", "black"],
|
||||
"settings_breadcrumbs": ["green", "black"]
|
||||
}
|
||||
|
||||
default_config_variables = {
|
||||
"db_file_path": os.path.join(app_directory, "client.db"),
|
||||
"log_file_path": os.path.join(app_directory, "client.log"),
|
||||
"message_prefix": ">>",
|
||||
"sent_message_prefix": ">> Sent",
|
||||
"notification_symbol": "*",
|
||||
"ack_implicit_str": "[◌]",
|
||||
"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,
|
||||
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN
|
||||
}
|
||||
|
||||
if not os.path.exists(json_file_path):
|
||||
with open(json_file_path, "w", encoding="utf-8") as json_file:
|
||||
formatted_json = format_json_single_line_arrays(default_config_variables)
|
||||
json_file.write(formatted_json)
|
||||
|
||||
# Ensure all default variables exist in the JSON file
|
||||
with open(json_file_path, "r", encoding="utf-8") as json_file:
|
||||
loaded_config = json.load(json_file)
|
||||
|
||||
# Check and add missing variables
|
||||
updated = update_dict(default_config_variables, loaded_config)
|
||||
|
||||
# Update the JSON file if any variables were missing
|
||||
if updated:
|
||||
formatted_json = format_json_single_line_arrays(loaded_config)
|
||||
with open(json_file_path, "w", encoding="utf-8") as json_file:
|
||||
json_file.write(formatted_json)
|
||||
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
|
||||
|
||||
return loaded_config
|
||||
|
||||
def assign_config_variables(loaded_config):
|
||||
# Assign values to local variables
|
||||
|
||||
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"]
|
||||
message_prefix = loaded_config["message_prefix"]
|
||||
sent_message_prefix = loaded_config["sent_message_prefix"]
|
||||
notification_symbol = loaded_config["notification_symbol"]
|
||||
ack_implicit_str = loaded_config["ack_implicit_str"]
|
||||
ack_str = loaded_config["ack_str"]
|
||||
nak_str = loaded_config["nak_str"]
|
||||
ack_unknown_str = loaded_config["ack_unknown_str"]
|
||||
theme = loaded_config["theme"]
|
||||
if theme == "dark":
|
||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
|
||||
elif theme == "light":
|
||||
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
|
||||
loaded_config = initialize_config()
|
||||
assign_config_variables(loaded_config)
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
filename="default_config.log",
|
||||
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
print("\nLoaded Configuration:")
|
||||
print(f"Database File Path: {db_file_path}")
|
||||
print(f"Log File Path: {log_file_path}")
|
||||
print(f"Message Prefix: {message_prefix}")
|
||||
print(f"Sent Message Prefix: {sent_message_prefix}")
|
||||
print(f"Notification Symbol: {notification_symbol}")
|
||||
print(f"ACK Implicit String: {ack_implicit_str}")
|
||||
print(f"ACK String: {ack_str}")
|
||||
print(f"NAK String: {nak_str}")
|
||||
print(f"ACK Unknown String: {ack_unknown_str}")
|
||||
print(f"Color Config: {COLOR_CONFIG}")
|
||||
12
globals.py
12
globals.py
@@ -1,15 +1,13 @@
|
||||
interface = None
|
||||
display_log = False
|
||||
all_messages = {}
|
||||
channel_list = []
|
||||
notifications = set()
|
||||
notifications = []
|
||||
packet_buffer = []
|
||||
node_list = []
|
||||
myNodeNum = 0
|
||||
selected_channel = 0
|
||||
selected_message = 0
|
||||
selected_node = 0
|
||||
current_window = 0
|
||||
interface = None
|
||||
display_log = False
|
||||
db_file_path = "client.db"
|
||||
message_prefix = ">>"
|
||||
sent_message_prefix = message_prefix + " Sent"
|
||||
notification_symbol = "*"
|
||||
lock = None
|
||||
225
input_handlers.py
Normal file
225
input_handlers.py
Normal file
@@ -0,0 +1,225 @@
|
||||
import curses
|
||||
import ipaddress
|
||||
from ui.colors import get_color
|
||||
|
||||
def get_text_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_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 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 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 - 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.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)
|
||||
|
||||
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
|
||||
return list_options[selected_index]
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
return current_option
|
||||
|
||||
|
||||
def move_highlight(old_idx, new_idx, options, list_win, list_pad):
|
||||
if old_idx == new_idx:
|
||||
return # no-op
|
||||
|
||||
list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default"))
|
||||
list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
|
||||
|
||||
list_win.refresh()
|
||||
|
||||
start_index = max(0, new_idx - (list_win.getmaxyx()[0] - 4))
|
||||
|
||||
list_win.refresh()
|
||||
list_pad.refresh(start_index, 0,
|
||||
list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4,
|
||||
list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + 4 + list_win.getmaxyx()[1] - 4)
|
||||
|
||||
72
main.py
72
main.py
@@ -1,39 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
'''
|
||||
Curses Client for Meshtastic by http://github.com/pdxlocations
|
||||
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
|
||||
Powered by Meshtastic.org
|
||||
V 1.0.0
|
||||
V 1.2.1
|
||||
'''
|
||||
|
||||
import curses
|
||||
from pubsub import pub
|
||||
import os
|
||||
import logging
|
||||
import traceback
|
||||
import threading
|
||||
|
||||
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
|
||||
from input_handlers import get_list_input
|
||||
from utilities.utils import get_channels, get_node_list, get_nodeNum
|
||||
from settings import set_region
|
||||
from db_handler import init_nodedb, load_messages_from_db
|
||||
import default_config as config
|
||||
import globals
|
||||
|
||||
# Set environment variables for ncurses compatibility
|
||||
import os
|
||||
|
||||
# Set ncurses compatibility settings
|
||||
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
|
||||
os.environ["TERM"] = "screen"
|
||||
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):
|
||||
draw_splash(stdscr)
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
globals.interface = initialize_interface(args)
|
||||
globals.channel_list = get_channels()
|
||||
pub.subscribe(on_receive, 'meshtastic.receive')
|
||||
init_nodedb()
|
||||
load_messages_from_db()
|
||||
main_ui(stdscr)
|
||||
try:
|
||||
draw_splash(stdscr)
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
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.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:
|
||||
logging.error("An error occurred: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
curses.wrapper(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,68 +1,105 @@
|
||||
from meshtastic import BROADCAST_NUM
|
||||
from utilities.utils import get_node_list, decimal_to_hex, get_nodeNum
|
||||
import globals
|
||||
import logging
|
||||
import time
|
||||
from utilities.utils import refresh_node_list
|
||||
from datetime import datetime
|
||||
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
|
||||
from db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db
|
||||
import default_config as config
|
||||
import globals
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
def on_receive(packet, interface):
|
||||
global nodes_win
|
||||
|
||||
# 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' in packet and packet['decoded']['portnum'] == 'NODEINFO_APP':
|
||||
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
|
||||
get_node_list()
|
||||
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()
|
||||
maybe_store_nodeinfo_in_db(packet)
|
||||
|
||||
elif 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
|
||||
message_bytes = packet['decoded']['payload']
|
||||
message_string = message_bytes.decode('utf-8')
|
||||
if packet.get('channel'):
|
||||
channel_number = packet['channel']
|
||||
else:
|
||||
channel_number = 0
|
||||
myNodeNum = get_nodeNum()
|
||||
if packet['to'] == myNodeNum:
|
||||
if packet['from'] in globals.channel_list:
|
||||
pass
|
||||
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:
|
||||
globals.channel_list.append(packet['from'])
|
||||
globals.all_messages[packet['from']] = []
|
||||
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)
|
||||
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]:
|
||||
add_notification(channel_number)
|
||||
|
||||
# Add received message to the messages list
|
||||
message_from_id = packet['from']
|
||||
message_from_string = ""
|
||||
for node in globals.interface.nodes.values():
|
||||
if message_from_id == node['num']:
|
||||
message_from_string = node["user"]["shortName"] + ":" # Get the name using the node ID
|
||||
break
|
||||
else:
|
||||
message_from_string = str(decimal_to_hex(message_from_id)) # If long name not found, use the ID as string
|
||||
|
||||
if globals.channel_list[channel_number] in globals.all_messages:
|
||||
globals.all_messages[globals.channel_list[channel_number]].append((f"{globals.message_prefix} {message_from_string} ", message_string))
|
||||
else:
|
||||
globals.all_messages[globals.channel_list[channel_number]] = [(f"{globals.message_prefix} {message_from_string} ", message_string)]
|
||||
|
||||
draw_channel_list()
|
||||
draw_messages_window()
|
||||
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
|
||||
|
||||
except KeyError as e:
|
||||
print(f"Error processing packet: {e}")
|
||||
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
|
||||
|
||||
except KeyError as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from datetime import datetime
|
||||
import google.protobuf.json_format
|
||||
from meshtastic import BROADCAST_NUM
|
||||
from db_handler import save_message_to_db
|
||||
from utilities.utils import get_nodeNum
|
||||
from meshtastic.protobuf import mesh_pb2, portnums_pb2
|
||||
|
||||
from db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db
|
||||
import default_config as config
|
||||
import globals
|
||||
|
||||
ack_naks = {}
|
||||
@@ -17,21 +21,105 @@ def onAckNak(packet):
|
||||
message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1]
|
||||
|
||||
confirm_string = " "
|
||||
ack_type = None
|
||||
if(packet['decoded']['routing']['errorReason'] == "NONE"):
|
||||
if(packet['from'] == get_nodeNum()): # Ack "from" ourself means implicit ACK
|
||||
confirm_string = "[◌]"
|
||||
if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK
|
||||
confirm_string = config.ack_implicit_str
|
||||
ack_type = "Implicit"
|
||||
else:
|
||||
confirm_string = "[✓]"
|
||||
confirm_string = config.ack_str
|
||||
ack_type = "Ack"
|
||||
else:
|
||||
confirm_string = "[x]"
|
||||
confirm_string = config.nak_str
|
||||
ack_type = "Nak"
|
||||
|
||||
globals.all_messages[acknak['channel']][acknak['messageIndex']] = (globals.sent_message_prefix + confirm_string + ": ", message)
|
||||
globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message)
|
||||
|
||||
update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type)
|
||||
|
||||
channel_number = globals.channel_list.index(acknak['channel'])
|
||||
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
|
||||
draw_messages_window()
|
||||
|
||||
def on_response_traceroute(packet):
|
||||
"""on response for trace route"""
|
||||
from ui.curses_ui import draw_channel_list, draw_messages_window, add_notification
|
||||
|
||||
refresh_channels = False
|
||||
refresh_messages = False
|
||||
|
||||
UNK_SNR = -128 # Value representing unknown SNR
|
||||
|
||||
route_discovery = mesh_pb2.RouteDiscovery()
|
||||
route_discovery.ParseFromString(packet["decoded"]["payload"])
|
||||
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
|
||||
|
||||
msg_str = "Traceroute to:\n"
|
||||
|
||||
route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response
|
||||
|
||||
# SNR list should have one more entry than the route, as the final destination adds its SNR also
|
||||
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
|
||||
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
|
||||
if lenTowards > 0: # Loop through hops in route and add SNR if available
|
||||
for idx, node_num in enumerate(msg_dict["route"]):
|
||||
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
|
||||
+ " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
# End with origin of response
|
||||
route_str += " --> " + (get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}") \
|
||||
+ " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
msg_str += route_str + "\n" # Print the route towards destination
|
||||
|
||||
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
|
||||
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
|
||||
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
|
||||
if backValid:
|
||||
msg_str += "Back:\n"
|
||||
route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response
|
||||
|
||||
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
|
||||
for idx, node_num in enumerate(msg_dict["routeBack"]):
|
||||
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
|
||||
+ " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
# End with destination of response (us)
|
||||
route_str += " --> " + (get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}") \
|
||||
+ " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)"
|
||||
|
||||
msg_str += route_str + "\n" # Print the route back to us
|
||||
|
||||
if(packet['from'] not in globals.channel_list):
|
||||
globals.channel_list.append(packet['from'])
|
||||
refresh_channels = True
|
||||
|
||||
if(is_chat_archived(packet['from'])):
|
||||
update_node_info_in_db(packet['from'], chat_archived=False)
|
||||
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
|
||||
refresh_messages = True
|
||||
else:
|
||||
add_notification(channel_number)
|
||||
refresh_channels = True
|
||||
|
||||
message_from_string = get_name_from_database(packet['from'], type='short') + ":\n"
|
||||
|
||||
if globals.channel_list[channel_number] not in globals.all_messages:
|
||||
globals.all_messages[globals.channel_list[channel_number]] = []
|
||||
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string}", msg_str))
|
||||
|
||||
if refresh_channels:
|
||||
draw_channel_list()
|
||||
if refresh_messages:
|
||||
draw_messages_window(True)
|
||||
save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str)
|
||||
|
||||
draw_messages_window()
|
||||
|
||||
def send_message(message, destination=BROADCAST_NUM, channel=0):
|
||||
|
||||
myid = get_nodeNum()
|
||||
myid = globals.myNodeNum
|
||||
send_on_channel = 0
|
||||
channel_id = globals.channel_list[channel]
|
||||
if isinstance(channel_id, int):
|
||||
@@ -50,12 +138,44 @@ def send_message(message, destination=BROADCAST_NUM, channel=0):
|
||||
)
|
||||
|
||||
# Add sent message to the messages dictionary
|
||||
if channel_id in globals.all_messages:
|
||||
globals.all_messages[channel_id].append((globals.sent_message_prefix + "[…]: ", message))
|
||||
if channel_id not in globals.all_messages:
|
||||
globals.all_messages[channel_id] = []
|
||||
|
||||
# Handle timestamp logic
|
||||
current_timestamp = int(datetime.now().timestamp()) # Get current timestamp
|
||||
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
|
||||
|
||||
# Retrieve the last timestamp if available
|
||||
channel_messages = globals.all_messages[channel_id]
|
||||
if channel_messages:
|
||||
# Check the last entry for a timestamp
|
||||
for entry in reversed(channel_messages):
|
||||
if entry[0].startswith("--"):
|
||||
last_hour = entry[0].strip("- ").strip()
|
||||
break
|
||||
else:
|
||||
last_hour = None
|
||||
else:
|
||||
globals.all_messages[channel_id] = [(globals.sent_message_prefix + "[…]: ", message)]
|
||||
last_hour = None
|
||||
|
||||
ack_naks[sent_message_data.id] = {'channel' : channel_id, 'messageIndex' : len(globals.all_messages[channel_id]) - 1 }
|
||||
# Add a new timestamp if it's a new hour
|
||||
if last_hour != current_hour:
|
||||
globals.all_messages[channel_id].append((f"-- {current_hour} --", ""))
|
||||
|
||||
globals.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message))
|
||||
|
||||
save_message_to_db(channel_id, myid, message)
|
||||
timestamp = save_message_to_db(channel_id, myid, message)
|
||||
|
||||
ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp}
|
||||
|
||||
def send_traceroute():
|
||||
r = mesh_pb2.RouteDiscovery()
|
||||
globals.interface.sendData(
|
||||
r,
|
||||
destinationId=globals.node_list[globals.selected_node],
|
||||
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
|
||||
wantResponse=True,
|
||||
onResponse=on_response_traceroute,
|
||||
channelIndex=0,
|
||||
hopLimit=3,
|
||||
)
|
||||
|
||||
131
save_to_radio.py
Normal file
131
save_to_radio.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from meshtastic.protobuf import channel_pb2
|
||||
from google.protobuf.message import Message
|
||||
import logging
|
||||
import base64
|
||||
from db_handler import update_node_info_in_db
|
||||
import globals
|
||||
|
||||
def save_changes(interface, menu_path, modified_settings):
|
||||
"""
|
||||
Save changes to the device based on modified settings.
|
||||
:param interface: Meshtastic interface instance
|
||||
:param menu_path: Current menu path
|
||||
:param modified_settings: Dictionary of modified settings
|
||||
"""
|
||||
try:
|
||||
if not modified_settings:
|
||||
logging.info("No changes to save. modified_settings is empty.")
|
||||
return
|
||||
|
||||
node = interface.getNode('^local')
|
||||
|
||||
if menu_path[1] == "Radio Settings" or menu_path[1] == "Module Settings":
|
||||
config_category = menu_path[2].lower() # for radio and module configs
|
||||
|
||||
if {'latitude', 'longitude', 'altitude'} & modified_settings.keys():
|
||||
lat = float(modified_settings.get('latitude', 0.0))
|
||||
lon = float(modified_settings.get('longitude', 0.0))
|
||||
alt = int(modified_settings.get('altitude', 0))
|
||||
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
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"
|
||||
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 # Normalize boolean
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed)
|
||||
|
||||
# Update only the changed fields and preserve others
|
||||
update_node_info_in_db(globals.myNodeNum, long_name=long_name, short_name=short_name, is_licensed=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
|
||||
config_category = "Channels"
|
||||
|
||||
try:
|
||||
channel = menu_path[-1]
|
||||
channel_num = int(channel.split()[-1]) - 1
|
||||
except (IndexError, ValueError) as e:
|
||||
channel_num = None
|
||||
|
||||
channel = node.channels[channel_num]
|
||||
for key, value in modified_settings.items():
|
||||
if key == 'psk': # Special case: decode Base64 for psk
|
||||
channel.settings.psk = base64.b64decode(value)
|
||||
elif key == 'position_precision': # Special case: module_settings
|
||||
channel.settings.module_settings.position_precision = value
|
||||
else:
|
||||
setattr(channel.settings, key, value) # Use setattr for other fields
|
||||
|
||||
if channel_num == 0:
|
||||
channel.role = channel_pb2.Channel.Role.PRIMARY
|
||||
else:
|
||||
channel.role = channel_pb2.Channel.Role.SECONDARY
|
||||
|
||||
node.writeChannel(channel_num)
|
||||
|
||||
logging.info(f"Updated Channel {channel_num} in {config_category}")
|
||||
logging.info(node.channels)
|
||||
return
|
||||
|
||||
else:
|
||||
config_category = None
|
||||
|
||||
for config_item, new_value in modified_settings.items():
|
||||
# Check if the category exists in localConfig
|
||||
if hasattr(node.localConfig, config_category):
|
||||
config_subcategory = getattr(node.localConfig, config_category)
|
||||
# Check if the category exists in moduleConfig
|
||||
elif hasattr(node.moduleConfig, config_category):
|
||||
config_subcategory = getattr(node.moduleConfig, config_category)
|
||||
else:
|
||||
logging.warning(f"Config category '{config_category}' not found in config.")
|
||||
continue
|
||||
|
||||
# Check if the config_item exists in the subcategory
|
||||
if hasattr(config_subcategory, config_item):
|
||||
field = getattr(config_subcategory, config_item)
|
||||
|
||||
try:
|
||||
if isinstance(field, (int, float, str, bool)): # Direct field types
|
||||
setattr(config_subcategory, config_item, new_value)
|
||||
logging.info(f"Updated {config_category}.{config_item} to {new_value}")
|
||||
elif isinstance(field, Message): # Handle protobuf sub-messages
|
||||
if isinstance(new_value, dict): # If new_value is a dictionary
|
||||
for sub_field, sub_value in new_value.items():
|
||||
if hasattr(field, sub_field):
|
||||
setattr(field, sub_field, sub_value)
|
||||
logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}")
|
||||
else:
|
||||
logging.warning(f"Sub-field '{sub_field}' not found in {config_category}.{config_item}")
|
||||
else:
|
||||
logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.")
|
||||
else:
|
||||
logging.warning(f"Unsupported field type for {config_category}.{config_item}.")
|
||||
except AttributeError as e:
|
||||
logging.error(f"Failed to update {config_category}.{config_item}: {e}")
|
||||
else:
|
||||
logging.warning(f"Config item '{config_item}' not found in config category '{config_category}'.")
|
||||
|
||||
# Write the configuration changes to the node
|
||||
try:
|
||||
node.writeConfig(config_category)
|
||||
logging.info(f"Changes written to config category: {config_category}")
|
||||
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}")
|
||||
1032
settings.py
1032
settings.py
File diff suppressed because it is too large
Load Diff
43
ui/colors.py
Normal file
43
ui/colors.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import curses
|
||||
import default_config as config
|
||||
|
||||
COLOR_MAP = {
|
||||
"black": curses.COLOR_BLACK,
|
||||
"red": curses.COLOR_RED,
|
||||
"green": curses.COLOR_GREEN,
|
||||
"yellow": curses.COLOR_YELLOW,
|
||||
"blue": curses.COLOR_BLUE,
|
||||
"magenta": curses.COLOR_MAGENTA,
|
||||
"cyan": curses.COLOR_CYAN,
|
||||
"white": curses.COLOR_WHITE
|
||||
}
|
||||
|
||||
def setup_colors(reinit=False):
|
||||
"""
|
||||
Initialize curses color pairs based on the COLOR_CONFIG.
|
||||
"""
|
||||
curses.start_color()
|
||||
if reinit:
|
||||
conf = config.initialize_config()
|
||||
config.assign_config_variables(conf)
|
||||
|
||||
for idx, (category, (fg_name, bg_name)) in enumerate(config.COLOR_CONFIG.items(), start=1):
|
||||
fg = COLOR_MAP.get(fg_name.lower(), curses.COLOR_WHITE)
|
||||
bg = COLOR_MAP.get(bg_name.lower(), curses.COLOR_BLACK)
|
||||
curses.init_pair(idx, fg, bg)
|
||||
config.COLOR_CONFIG[category] = idx
|
||||
print()
|
||||
|
||||
|
||||
def get_color(category, bold=False, reverse=False, underline=False):
|
||||
"""
|
||||
Retrieve a curses color pair with optional attributes.
|
||||
"""
|
||||
color = curses.color_pair(config.COLOR_CONFIG[category])
|
||||
if bold:
|
||||
color |= curses.A_BOLD
|
||||
if reverse:
|
||||
color |= curses.A_REVERSE
|
||||
if underline:
|
||||
color |= curses.A_UNDERLINE
|
||||
return color
|
||||
836
ui/curses_ui.py
836
ui/curses_ui.py
@@ -1,45 +1,318 @@
|
||||
import curses
|
||||
import textwrap
|
||||
import logging
|
||||
import traceback
|
||||
from utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
|
||||
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, update_node_info_in_db, is_chat_archived
|
||||
import default_config as config
|
||||
import ui.dialog
|
||||
import globals
|
||||
from utilities.utils import get_node_list, get_name_from_number, get_channels
|
||||
from settings import settings
|
||||
from message_handlers.tx_handler import send_message
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
def add_notification(channel_number):
|
||||
handle_notification(channel_number, add=True)
|
||||
|
||||
def remove_notification(channel_number):
|
||||
handle_notification(channel_number, add=False)
|
||||
channel_win.box()
|
||||
|
||||
def handle_notification(channel_number, add=True):
|
||||
if add:
|
||||
globals.notifications.add(channel_number) # Add the channel to the notification tracker
|
||||
else:
|
||||
globals.notifications.discard(channel_number) # Remove the channel from the notification tracker
|
||||
for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]:
|
||||
win.erase()
|
||||
|
||||
def draw_text_field(win, text):
|
||||
win.border()
|
||||
win.addstr(1, 1, text)
|
||||
entry_win.resize(3, width)
|
||||
|
||||
def draw_centered_text_field(win, text, y_offset = 0):
|
||||
height, width = win.getmaxyx()
|
||||
x = (width - len(text)) // 2
|
||||
y = (height // 2) + y_offset
|
||||
win.addstr(y, x, text)
|
||||
win.refresh()
|
||||
channel_win.resize(height - 6, channel_width)
|
||||
|
||||
def draw_debug(value):
|
||||
function_win.addstr(1, 1, f"debug: {value} ")
|
||||
function_win.refresh()
|
||||
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):
|
||||
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
|
||||
handle_resize(stdscr, True)
|
||||
|
||||
input_text = ""
|
||||
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
|
||||
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()
|
||||
|
||||
elif char == chr(31):
|
||||
if(globals.current_window == 2 or globals.current_window == 0):
|
||||
search(globals.current_window)
|
||||
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if(isinstance(char, str)):
|
||||
input_text += char
|
||||
else:
|
||||
input_text += chr(char)
|
||||
|
||||
def draw_splash(stdscr):
|
||||
curses.start_color()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) # Green text on black background
|
||||
setup_colors()
|
||||
curses.curs_set(0)
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.bkgd(get_color("background"))
|
||||
|
||||
height, width = stdscr.getmaxyx()
|
||||
message_1 = "/ Λ"
|
||||
message_2 = "/ / \\"
|
||||
@@ -49,136 +322,179 @@ def draw_splash(stdscr):
|
||||
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, curses.color_pair(1) | curses.A_BOLD)
|
||||
stdscr.addstr(start_y+1, start_x-1, message_2, curses.color_pair(1) | curses.A_BOLD)
|
||||
stdscr.addstr(start_y+2, start_x-2, message_3, curses.color_pair(1) | curses.A_BOLD)
|
||||
stdscr.addstr(start_y+4, start_x2, message_4)
|
||||
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_win.clear()
|
||||
channel_pad.erase()
|
||||
win_height, win_width = channel_win.getmaxyx()
|
||||
start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders
|
||||
|
||||
for i, channel in enumerate(list(globals.all_messages.keys())[start_index:], start=0):
|
||||
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):
|
||||
channel = get_name_from_number(channel, type='long')
|
||||
if is_chat_archived(channel):
|
||||
continue
|
||||
channel = get_name_from_database(channel, type='long')
|
||||
|
||||
# Determine whether to add the notification
|
||||
notification = " " + globals.notification_symbol if start_index + i in globals.notifications else ""
|
||||
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
|
||||
if i < win_height - 2 : # Check if there is enough space in the window
|
||||
if start_index + i == globals.selected_channel and globals.current_window == 0:
|
||||
channel_win.addstr(i + 1, 1, truncated_channel + notification, curses.color_pair(3))
|
||||
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:
|
||||
channel_win.addstr(i + 1, 1, truncated_channel + notification, curses.color_pair(4))
|
||||
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():
|
||||
def draw_messages_window(scroll_to_bottom = False):
|
||||
"""Update the messages window based on the selected channel and scroll position."""
|
||||
messages_win.clear()
|
||||
messages_pad.erase()
|
||||
|
||||
channel = globals.channel_list[globals.selected_channel]
|
||||
|
||||
if channel in globals.all_messages:
|
||||
messages = globals.all_messages[channel]
|
||||
num_messages = len(messages)
|
||||
max_messages = messages_win.getmaxyx()[0] - 2 # Max messages that fit in the window
|
||||
|
||||
# Adjust for packetlog height if log is visible
|
||||
if globals.display_log:
|
||||
packetlog_height = packetlog_win.getmaxyx()[0]
|
||||
max_messages -= packetlog_height - 1
|
||||
if max_messages < 1:
|
||||
max_messages = 1
|
||||
msg_line_count = 0
|
||||
|
||||
# Calculate the scroll position based on the current selection
|
||||
max_scroll_position = max(0, num_messages - max_messages)
|
||||
start_index = max(0, min(globals.selected_message, max_scroll_position))
|
||||
|
||||
# Display messages starting from the calculated start index
|
||||
row = 1
|
||||
for index, (prefix, message) in enumerate(messages[start_index:start_index + max_messages], start=start_index):
|
||||
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:
|
||||
# Highlight the row if it's the selected message
|
||||
if index == globals.selected_message and globals.current_window == 1:
|
||||
color = curses.color_pair(3) # Highlighted row color
|
||||
if prefix.startswith("--"):
|
||||
color = get_color("timestamps")
|
||||
elif prefix.startswith(config.sent_message_prefix):
|
||||
color = get_color("tx_messages")
|
||||
else:
|
||||
color = curses.color_pair(1) if prefix.startswith(globals.sent_message_prefix) else curses.color_pair(2)
|
||||
messages_win.addstr(row, 1, line, color)
|
||||
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
|
||||
|
||||
nodes_win.clear()
|
||||
win_height = nodes_win.getmaxyx()[0]
|
||||
start_index = max(0, globals.selected_node - (win_height - 3)) # Calculate starting index based on selected node and window height
|
||||
# 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)
|
||||
|
||||
for i, node in enumerate(get_node_list()[start_index:], start=1):
|
||||
if i < win_height - 1 : # Check if there is enough space in the window
|
||||
if globals.selected_node + 1 == start_index + i and globals.current_window == 2:
|
||||
nodes_win.addstr(i, 1, get_name_from_number(node, "long"), curses.color_pair(3))
|
||||
else:
|
||||
nodes_win.addstr(i, 1, get_name_from_number(node, "long"), curses.color_pair(4))
|
||||
|
||||
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]
|
||||
nodes_pad.addstr(i, 1, node_str, get_color("node_list", 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)
|
||||
|
||||
def select_channels(direction):
|
||||
channel_list_length = len(globals.channel_list)
|
||||
globals.selected_channel += direction
|
||||
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)
|
||||
|
||||
if globals.selected_channel < 0:
|
||||
globals.selected_channel = channel_list_length - 1
|
||||
elif globals.selected_channel >= channel_list_length:
|
||||
globals.selected_channel = 0
|
||||
# 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)
|
||||
|
||||
draw_channel_list()
|
||||
draw_messages_window()
|
||||
def scroll_channels(direction):
|
||||
new_selected_channel = globals.selected_channel + direction
|
||||
|
||||
def select_messages(direction):
|
||||
messages_length = len(globals.all_messages[globals.channel_list[globals.selected_channel]])
|
||||
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
|
||||
|
||||
if globals.selected_message < 0:
|
||||
globals.selected_message = messages_length - 1
|
||||
elif globals.selected_message >= messages_length:
|
||||
globals.selected_message = 0
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = max(0, min(globals.selected_message, msg_line_count - get_msg_window_lines()))
|
||||
|
||||
draw_messages_window()
|
||||
refresh_pad(1)
|
||||
|
||||
def select_nodes(direction):
|
||||
node_list_length = len(get_node_list())
|
||||
globals.selected_node += direction
|
||||
def select_node(idx):
|
||||
old_selected_node = globals.selected_node
|
||||
globals.selected_node = max(0, min(idx, len(globals.node_list) - 1))
|
||||
|
||||
if globals.selected_node < 0:
|
||||
globals.selected_node = node_list_length - 1
|
||||
elif globals.selected_node >= node_list_length:
|
||||
globals.selected_node = 0
|
||||
highlight_line(False, 2, old_selected_node)
|
||||
highlight_line(True, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
draw_node_list()
|
||||
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():
|
||||
|
||||
@@ -186,7 +502,7 @@ def draw_packetlog_win():
|
||||
span = 0
|
||||
|
||||
if globals.display_log:
|
||||
packetlog_win.clear()
|
||||
packetlog_win.erase()
|
||||
height, width = packetlog_win.getmaxyx()
|
||||
|
||||
for column in columns[:-1]:
|
||||
@@ -194,17 +510,17 @@ def draw_packetlog_win():
|
||||
|
||||
# Add headers
|
||||
headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}"
|
||||
packetlog_win.addstr(1, 1, headers[:width - 2],curses.A_UNDERLINE) # Truncate headers if they exceed window width
|
||||
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_number(packet['from'], 'short').ljust(columns[0])
|
||||
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_number(packet['to'], 'short').ljust(columns[1])
|
||||
else get_name_from_database(packet['to'], 'short').ljust(columns[1])
|
||||
)
|
||||
if 'decoded' in packet:
|
||||
port = packet['decoded']['portnum'].ljust(columns[2])
|
||||
@@ -218,161 +534,187 @@ def draw_packetlog_win():
|
||||
logString = logString[:width - 3]
|
||||
|
||||
# Add to the window
|
||||
packetlog_win.addstr(i + 2, 1, logString)
|
||||
|
||||
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
|
||||
|
||||
packetlog_win.attrset(get_color("window_frame"))
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
|
||||
def search(win):
|
||||
start_idx = globals.selected_node
|
||||
select_func = select_node
|
||||
|
||||
def main_ui(stdscr):
|
||||
global messages_win, nodes_win, channel_win, function_win, packetlog_win
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
if win == 0:
|
||||
start_idx = globals.selected_channel
|
||||
select_func = select_channel
|
||||
|
||||
# Initialize colors
|
||||
curses.start_color()
|
||||
curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK)
|
||||
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||
curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLACK)
|
||||
curses.init_pair(5, curses.COLOR_RED, curses.COLOR_BLACK)
|
||||
|
||||
# Calculate window max dimensions
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Define window dimensions and positions
|
||||
entry_win = curses.newwin(3, width, 0, 0)
|
||||
channel_width = 3 * (width // 16)
|
||||
nodes_width = 5 * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
|
||||
channel_win = curses.newwin(height - 6, channel_width, 3, 0)
|
||||
messages_win = curses.newwin(height - 6, messages_width, 3, channel_width)
|
||||
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 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)
|
||||
|
||||
draw_centered_text_field(function_win, f"↑→↓← = Select ENTER = Send ` = Settings / = Toggle Log ESC = Quit")
|
||||
|
||||
# Enable scrolling for messages and nodes windows
|
||||
messages_win.scrollok(True)
|
||||
nodes_win.scrollok(True)
|
||||
channel_win.scrollok(True)
|
||||
|
||||
draw_channel_list()
|
||||
draw_node_list()
|
||||
draw_messages_window()
|
||||
|
||||
# Draw boxes around windows
|
||||
channel_win.box()
|
||||
entry_win.box()
|
||||
messages_win.box()
|
||||
nodes_win.box()
|
||||
function_win.box()
|
||||
|
||||
# Refresh all windows
|
||||
entry_win.refresh()
|
||||
messages_win.refresh()
|
||||
nodes_win.refresh()
|
||||
channel_win.refresh()
|
||||
function_win.refresh()
|
||||
|
||||
input_text = ""
|
||||
|
||||
entry_win.keypad(True)
|
||||
curses.curs_set(1)
|
||||
search_text = ""
|
||||
entry_win.erase()
|
||||
|
||||
while True:
|
||||
draw_text_field(entry_win, f"Input: {input_text}")
|
||||
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
|
||||
char = entry_win.get_wch()
|
||||
|
||||
# Get user input from entry window
|
||||
entry_win.move(1, len(input_text) + 8)
|
||||
char = entry_win.getch()
|
||||
|
||||
# draw_debug(f"Keypress: {char}")
|
||||
|
||||
if char == curses.KEY_UP:
|
||||
if globals.current_window == 0:
|
||||
select_channels(-1)
|
||||
globals.selected_message = len(globals.all_messages[globals.channel_list[globals.selected_channel]]) - 1
|
||||
elif globals.current_window == 1:
|
||||
select_messages(-1)
|
||||
elif globals.current_window == 2:
|
||||
select_nodes(-1)
|
||||
|
||||
elif char == curses.KEY_DOWN:
|
||||
if globals.current_window == 0:
|
||||
select_channels(1)
|
||||
globals.selected_message = len(globals.all_messages[globals.channel_list[globals.selected_channel]]) - 1
|
||||
elif globals.current_window == 1:
|
||||
select_messages(1)
|
||||
elif globals.current_window == 2:
|
||||
select_nodes(1)
|
||||
|
||||
elif char == curses.KEY_LEFT:
|
||||
globals.current_window = (globals.current_window - 1) % 3
|
||||
draw_channel_list()
|
||||
draw_node_list()
|
||||
draw_messages_window()
|
||||
|
||||
elif char == curses.KEY_RIGHT:
|
||||
globals.current_window = (globals.current_window + 1) % 3
|
||||
draw_channel_list()
|
||||
draw_node_list()
|
||||
draw_messages_window()
|
||||
|
||||
# Check for Esc
|
||||
elif char == 27:
|
||||
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
break
|
||||
|
||||
elif char == curses.KEY_ENTER or char == 10 or char == 13:
|
||||
if globals.current_window == 2:
|
||||
node_list = get_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()
|
||||
|
||||
else:
|
||||
# Enter key pressed, send user input as message
|
||||
send_message(input_text, channel=globals.selected_channel)
|
||||
draw_messages_window()
|
||||
|
||||
# Clear entry window and reset input text
|
||||
input_text = ""
|
||||
entry_win.clear()
|
||||
# entry_win.refresh()
|
||||
|
||||
elif char == curses.KEY_BACKSPACE or char == 127:
|
||||
if input_text:
|
||||
input_text = input_text[:-1]
|
||||
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.refresh()
|
||||
|
||||
elif char == 96:
|
||||
curses.curs_set(0) # Hide cursor
|
||||
settings(stdscr)
|
||||
curses.curs_set(1) # Show cursor again
|
||||
|
||||
elif char == 47:
|
||||
# Display packet log
|
||||
if globals.display_log is False:
|
||||
globals.display_log = True
|
||||
draw_messages_window()
|
||||
else:
|
||||
globals.display_log = False
|
||||
packetlog_win.clear()
|
||||
draw_messages_window()
|
||||
else:
|
||||
# Append typed character to input text
|
||||
input_text += chr(char)
|
||||
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"]
|
||||
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 == 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()
|
||||
43
ui/dialog.py
Normal file
43
ui/dialog.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import curses
|
||||
from ui.colors import get_color
|
||||
|
||||
def dialog(stdscr, title, message):
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Calculate dialog dimensions
|
||||
max_line_lengh = 0
|
||||
message_lines = message.splitlines()
|
||||
for l in message_lines:
|
||||
max_line_length = max(len(l), max_line_lengh)
|
||||
dialog_width = max(len(title) + 4, max_line_length + 4)
|
||||
dialog_height = len(message_lines) + 4
|
||||
x = (width - dialog_width) // 2
|
||||
y = (height - dialog_height) // 2
|
||||
|
||||
# Create dialog window
|
||||
win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||
win.bkgd(get_color("background"))
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.border(0)
|
||||
|
||||
# Add title
|
||||
win.addstr(0, 2, title, get_color("settings_default"))
|
||||
|
||||
# Add message
|
||||
for i, l in enumerate(message_lines):
|
||||
win.addstr(2 + i, 2, l, get_color("settings_default"))
|
||||
|
||||
# Add button
|
||||
win.addstr(dialog_height - 2, (dialog_width - 4) // 2, " Ok ", get_color("settings_default", reverse=True))
|
||||
|
||||
# Refresh dialog window
|
||||
win.refresh()
|
||||
|
||||
# Get user input
|
||||
while True:
|
||||
char = win.getch()
|
||||
# Close dialog with enter, space, or esc
|
||||
if char in(curses.KEY_ENTER, 10, 13, 32, 27):
|
||||
win.erase()
|
||||
win.refresh()
|
||||
return
|
||||
124
ui/menus.py
Normal file
124
ui/menus.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from collections import OrderedDict
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2
|
||||
import logging
|
||||
import base64
|
||||
|
||||
|
||||
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}
|
||||
|
||||
if not hasattr(message_instance, "DESCRIPTOR"):
|
||||
return {}
|
||||
|
||||
menu = {}
|
||||
fields = message_instance.DESCRIPTOR.fields
|
||||
for field in fields:
|
||||
if field.name in {"sessionkey", "channel_num", "id", "ignore_incoming"}: # Skip certain 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
|
||||
menu[field.name] = extract_fields(nested_instance, nested_config)
|
||||
elif field.enum_type: # Handle enum fields
|
||||
current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set"
|
||||
if isinstance(current_value, int): # If the value is a number, map it to its name
|
||||
enum_value = field.enum_type.values_by_number.get(current_value)
|
||||
if enum_value: # Check if the enum value exists
|
||||
current_value_name = f"{enum_value.name}"
|
||||
else:
|
||||
current_value_name = f"Unknown ({current_value})"
|
||||
menu[field.name] = (field, current_value_name)
|
||||
else:
|
||||
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)
|
||||
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"
|
||||
else:
|
||||
logging.info("Node Info not available")
|
||||
menu_structure["Main Menu"]["User Settings"] = "Node Info not available"
|
||||
|
||||
# Add Channels
|
||||
channel = channel_pb2.ChannelSettings()
|
||||
menu_structure["Main Menu"]["Channels"] = {}
|
||||
if interface:
|
||||
for i in range(8):
|
||||
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
|
||||
radio = config_pb2.Config()
|
||||
current_radio_config = interface.localNode.localConfig if interface else None
|
||||
menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config)
|
||||
|
||||
# Add Lat/Lon/Alt
|
||||
position_data = {
|
||||
"latitude": (None, current_node_info["position"].get("latitude", 0.0)),
|
||||
"longitude": (None, current_node_info["position"].get("longitude", 0.0)),
|
||||
"altitude": (None, current_node_info["position"].get("altitude", 0))
|
||||
}
|
||||
|
||||
# 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():
|
||||
if key == "fixed_position": # Insert before or after a specific key
|
||||
ordered_position_menu[key] = value
|
||||
ordered_position_menu.update(position_data) # Insert Lat/Lon/Alt **right here**
|
||||
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
|
||||
|
||||
# Add Exit option
|
||||
menu_structure["Main Menu"]["Exit"] = None
|
||||
|
||||
return menu_structure
|
||||
321
user_config.py
Normal file
321
user_config.py
Normal file
@@ -0,0 +1,321 @@
|
||||
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 get_list_input
|
||||
|
||||
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 = 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):
|
||||
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 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 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)
|
||||
281
utilities/config_io.py
Normal file
281
utilities/config_io.py
Normal file
@@ -0,0 +1,281 @@
|
||||
|
||||
import yaml
|
||||
import logging
|
||||
from typing import List
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from meshtastic import BROADCAST_ADDR, mt_config
|
||||
from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
|
||||
|
||||
# defs are from meshtastic/python/main
|
||||
|
||||
def traverseConfig(config_root, config, interface_config) -> bool:
|
||||
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
|
||||
snake_name = camel_to_snake(config_root)
|
||||
for pref in config:
|
||||
pref_name = f"{snake_name}.{pref}"
|
||||
if isinstance(config[pref], dict):
|
||||
traverseConfig(pref_name, config[pref], interface_config)
|
||||
else:
|
||||
setPref(interface_config, pref_name, config[pref])
|
||||
|
||||
return True
|
||||
|
||||
def splitCompoundName(comp_name: str) -> List[str]:
|
||||
"""Split compound (dot separated) preference name into parts"""
|
||||
name: List[str] = comp_name.split(".")
|
||||
if len(name) < 2:
|
||||
name[0] = comp_name
|
||||
name.append(comp_name)
|
||||
return name
|
||||
|
||||
def setPref(config, comp_name, raw_val) -> bool:
|
||||
"""Set a channel or preferences value"""
|
||||
|
||||
name = splitCompoundName(comp_name)
|
||||
|
||||
snake_name = camel_to_snake(name[-1])
|
||||
camel_name = snake_to_camel(name[-1])
|
||||
uni_name = camel_name if mt_config.camel_case else snake_name
|
||||
logging.debug(f"snake_name:{snake_name}")
|
||||
logging.debug(f"camel_name:{camel_name}")
|
||||
|
||||
objDesc = config.DESCRIPTOR
|
||||
config_part = config
|
||||
config_type = objDesc.fields_by_name.get(name[0])
|
||||
if config_type and config_type.message_type is not None:
|
||||
for name_part in name[1:-1]:
|
||||
part_snake_name = camel_to_snake((name_part))
|
||||
config_part = getattr(config, config_type.name)
|
||||
config_type = config_type.message_type.fields_by_name.get(part_snake_name)
|
||||
pref = None
|
||||
if config_type and config_type.message_type is not None:
|
||||
pref = config_type.message_type.fields_by_name.get(snake_name)
|
||||
# Others like ChannelSettings are standalone
|
||||
elif config_type:
|
||||
pref = config_type
|
||||
|
||||
if (not pref) or (not config_type):
|
||||
return False
|
||||
|
||||
if isinstance(raw_val, str):
|
||||
val = fromStr(raw_val)
|
||||
else:
|
||||
val = raw_val
|
||||
logging.debug(f"valStr:{raw_val} val:{val}")
|
||||
|
||||
if snake_name == "wifi_psk" and len(str(raw_val)) < 8:
|
||||
logging.info(f"Warning: network.wifi_psk must be 8 or more characters.")
|
||||
return False
|
||||
|
||||
enumType = pref.enum_type
|
||||
# pylint: disable=C0123
|
||||
if enumType and type(val) == str:
|
||||
# We've failed so far to convert this string into an enum, try to find it by reflection
|
||||
e = enumType.values_by_name.get(val)
|
||||
if e:
|
||||
val = e.number
|
||||
else:
|
||||
logging.info(
|
||||
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
|
||||
)
|
||||
logging.info(f"Choices in sorted order are:")
|
||||
names = []
|
||||
for f in enumType.values:
|
||||
# Note: We must use the value of the enum (regardless if camel or snake case)
|
||||
names.append(f"{f.name}")
|
||||
for temp_name in sorted(names):
|
||||
logging.info(f" {temp_name}")
|
||||
return False
|
||||
|
||||
# repeating fields need to be handled with append, not setattr
|
||||
if pref.label != pref.LABEL_REPEATED:
|
||||
try:
|
||||
if config_type.message_type is not None:
|
||||
config_values = getattr(config_part, config_type.name)
|
||||
setattr(config_values, pref.name, val)
|
||||
else:
|
||||
setattr(config_part, snake_name, val)
|
||||
except TypeError:
|
||||
# The setter didn't like our arg type guess try again as a string
|
||||
config_values = getattr(config_part, config_type.name)
|
||||
setattr(config_values, pref.name, str(val))
|
||||
elif type(val) == list:
|
||||
new_vals = [fromStr(x) for x in val]
|
||||
config_values = getattr(config, config_type.name)
|
||||
getattr(config_values, pref.name)[:] = new_vals
|
||||
else:
|
||||
config_values = getattr(config, config_type.name)
|
||||
if val == 0:
|
||||
# clear values
|
||||
logging.info(f"Clearing {pref.name} list")
|
||||
del getattr(config_values, pref.name)[:]
|
||||
else:
|
||||
logging.info(f"Adding '{raw_val}' to the {pref.name} list")
|
||||
cur_vals = [x for x in getattr(config_values, pref.name) if x not in [0, "", b""]]
|
||||
cur_vals.append(val)
|
||||
getattr(config_values, pref.name)[:] = cur_vals
|
||||
return True
|
||||
|
||||
prefix = f"{'.'.join(name[0:-1])}." if config_type.message_type is not None else ""
|
||||
logging.info(f"Set {prefix}{uni_name} to {raw_val}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def config_import(interface, filename):
|
||||
with open(filename, encoding="utf8") as file:
|
||||
configuration = yaml.safe_load(file)
|
||||
closeNow = True
|
||||
|
||||
interface.getNode('^local', False).beginSettingsTransaction()
|
||||
|
||||
if "owner" in configuration:
|
||||
logging.info(f"Setting device owner to {configuration['owner']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode('^local', False).setOwner(configuration["owner"])
|
||||
|
||||
if "owner_short" in configuration:
|
||||
logging.info(
|
||||
f"Setting device owner short to {configuration['owner_short']}"
|
||||
)
|
||||
waitForAckNak = True
|
||||
interface.getNode('^local', False).setOwner(
|
||||
long_name=None, short_name=configuration["owner_short"]
|
||||
)
|
||||
|
||||
if "ownerShort" in configuration:
|
||||
logging.info(
|
||||
f"Setting device owner short to {configuration['ownerShort']}"
|
||||
)
|
||||
waitForAckNak = True
|
||||
interface.getNode('^local', False).setOwner(
|
||||
long_name=None, short_name=configuration["ownerShort"]
|
||||
)
|
||||
|
||||
if "channel_url" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channel_url']}")
|
||||
interface.getNode('^local').setURL(configuration["channel_url"])
|
||||
|
||||
if "channelUrl" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channelUrl']}")
|
||||
interface.getNode('^local').setURL(configuration["channelUrl"])
|
||||
|
||||
if "location" in configuration:
|
||||
alt = 0
|
||||
lat = 0.0
|
||||
lon = 0.0
|
||||
localConfig = interface.localNode.localConfig
|
||||
|
||||
if "alt" in configuration["location"]:
|
||||
alt = int(configuration["location"]["alt"] or 0)
|
||||
logging.info(f"Fixing altitude at {alt} meters")
|
||||
if "lat" in configuration["location"]:
|
||||
lat = float(configuration["location"]["lat"] or 0)
|
||||
logging.info(f"Fixing latitude at {lat} degrees")
|
||||
if "lon" in configuration["location"]:
|
||||
lon = float(configuration["location"]["lon"] or 0)
|
||||
logging.info(f"Fixing longitude at {lon} degrees")
|
||||
logging.info("Setting device position")
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
|
||||
if "config" in configuration:
|
||||
localConfig = interface.getNode('^local').localConfig
|
||||
for section in configuration["config"]:
|
||||
traverseConfig(
|
||||
section, configuration["config"][section], localConfig
|
||||
)
|
||||
interface.getNode('^local').writeConfig(
|
||||
camel_to_snake(section)
|
||||
)
|
||||
|
||||
if "module_config" in configuration:
|
||||
moduleConfig = interface.getNode('^local').moduleConfig
|
||||
for section in configuration["module_config"]:
|
||||
traverseConfig(
|
||||
section,
|
||||
configuration["module_config"][section],
|
||||
moduleConfig,
|
||||
)
|
||||
interface.getNode('^local').writeConfig(
|
||||
camel_to_snake(section)
|
||||
)
|
||||
|
||||
interface.getNode('^local', False).commitSettingsTransaction()
|
||||
logging.info("Writing modified configuration to device")
|
||||
|
||||
|
||||
|
||||
def config_export(interface) -> str:
|
||||
"""used in --export-config"""
|
||||
configObj = {}
|
||||
|
||||
owner = interface.getLongName()
|
||||
owner_short = interface.getShortName()
|
||||
channel_url = interface.localNode.getURL()
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
pos = myinfo.get("position")
|
||||
lat = None
|
||||
lon = None
|
||||
alt = None
|
||||
if pos:
|
||||
lat = pos.get("latitude")
|
||||
lon = pos.get("longitude")
|
||||
alt = pos.get("altitude")
|
||||
|
||||
if owner:
|
||||
configObj["owner"] = owner
|
||||
if owner_short:
|
||||
configObj["owner_short"] = owner_short
|
||||
if channel_url:
|
||||
if mt_config.camel_case:
|
||||
configObj["channelUrl"] = channel_url
|
||||
else:
|
||||
configObj["channel_url"] = channel_url
|
||||
# lat and lon don't make much sense without the other (so fill with 0s), and alt isn't meaningful without both
|
||||
if lat or lon:
|
||||
configObj["location"] = {"lat": lat or float(0), "lon": lon or float(0)}
|
||||
if alt:
|
||||
configObj["location"]["alt"] = alt
|
||||
|
||||
config = MessageToDict(interface.localNode.localConfig) #checkme - Used as a dictionary here and a string below
|
||||
if config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
for pref in config:
|
||||
if mt_config.camel_case:
|
||||
prefs[snake_to_camel(pref)] = config[pref]
|
||||
else:
|
||||
prefs[pref] = config[pref]
|
||||
# mark base64 encoded fields as such
|
||||
if pref == "security":
|
||||
if 'privateKey' in prefs[pref]:
|
||||
prefs[pref]['privateKey'] = 'base64:' + prefs[pref]['privateKey']
|
||||
if 'publicKey' in prefs[pref]:
|
||||
prefs[pref]['publicKey'] = 'base64:' + prefs[pref]['publicKey']
|
||||
if 'adminKey' in prefs[pref]:
|
||||
for i in range(len(prefs[pref]['adminKey'])):
|
||||
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
|
||||
if mt_config.camel_case:
|
||||
configObj["config"] = config #Identical command here and 2 lines below?
|
||||
else:
|
||||
configObj["config"] = config
|
||||
|
||||
module_config = MessageToDict(interface.localNode.moduleConfig)
|
||||
if module_config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
for pref in module_config:
|
||||
if len(module_config[pref]) > 0:
|
||||
prefs[pref] = module_config[pref]
|
||||
if mt_config.camel_case:
|
||||
configObj["module_config"] = prefs
|
||||
else:
|
||||
configObj["module_config"] = prefs
|
||||
|
||||
config_txt = "# start of Meshtastic configure yaml\n" #checkme - "config" (now changed to config_out)
|
||||
#was used as a string here and a Dictionary above
|
||||
config_txt += yaml.dump(configObj)
|
||||
|
||||
# logging.info(config_txt)
|
||||
return config_txt
|
||||
@@ -1,17 +1,23 @@
|
||||
import meshtastic.serial_interface
|
||||
import meshtastic.tcp_interface
|
||||
import meshtastic.ble_interface
|
||||
import logging
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
import globals
|
||||
|
||||
def initialize_interface(args):
|
||||
if args.ble:
|
||||
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
|
||||
elif args.host:
|
||||
return meshtastic.tcp_interface.TCPInterface(args.host)
|
||||
else:
|
||||
try:
|
||||
return meshtastic.serial_interface.SerialInterface(args.port)
|
||||
except PermissionError as ex:
|
||||
print("You probably need to add yourself to the `dialout` group to use a serial connection.")
|
||||
if globals.interface.devPath is None:
|
||||
return meshtastic.tcp_interface.TCPInterface("meshtastic.local")
|
||||
try:
|
||||
if args.ble:
|
||||
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
|
||||
elif args.host:
|
||||
return meshtastic.tcp_interface.TCPInterface(args.host)
|
||||
else:
|
||||
try:
|
||||
return meshtastic.serial_interface.SerialInterface(args.port)
|
||||
except PermissionError as ex:
|
||||
logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}")
|
||||
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")
|
||||
|
||||
except Exception as ex:
|
||||
logging.critical(f"Fatal error initializing interface: {ex}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import globals
|
||||
import datetime
|
||||
from meshtastic.protobuf import config_pb2
|
||||
import re
|
||||
import default_config as config
|
||||
|
||||
def get_channels():
|
||||
"""Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
|
||||
@@ -34,11 +35,29 @@ def get_channels():
|
||||
return globals.channel_list
|
||||
|
||||
def get_node_list():
|
||||
node_list = []
|
||||
if globals.interface.nodes:
|
||||
for node in globals.interface.nodes.values():
|
||||
node_list.append(node['num'])
|
||||
return node_list
|
||||
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)
|
||||
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()
|
||||
@@ -53,29 +72,45 @@ def convert_to_camel_case(string):
|
||||
camel_case_string = ''.join(word.capitalize() for word in words)
|
||||
return camel_case_string
|
||||
|
||||
def get_name_from_number(number, type='long'):
|
||||
name = ""
|
||||
for node in globals.interface.nodes.values():
|
||||
if number == node['num']:
|
||||
if type == 'long':
|
||||
name = node['user']['longName']
|
||||
return name
|
||||
elif type == 'short':
|
||||
name = node['user']['shortName']
|
||||
return name
|
||||
else:
|
||||
pass
|
||||
else:
|
||||
name = str(decimal_to_hex(number)) # If long name not found, use the ID as string
|
||||
return name
|
||||
def get_time_val_units(time_delta):
|
||||
value = 0
|
||||
unit = ""
|
||||
|
||||
def sanitize_string(input_str: str) -> str:
|
||||
"""Check if the string starts with a letter (a-z, A-Z) or an underscore (_), and replace all non-alpha/numeric/underscore characters with underscores."""
|
||||
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)
|
||||
|
||||
if not re.match(r'^[a-zA-Z_]', input_str):
|
||||
# If not, add "_"
|
||||
input_str = '_' + input_str
|
||||
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"
|
||||
|
||||
# Replace special characters with underscores (for database tables)
|
||||
sanitized_str: str = re.sub(r'[^a-zA-Z0-9_]', '_', input_str)
|
||||
return sanitized_str
|
||||
Reference in New Issue
Block a user