mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
68 Commits
v1.2.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 |
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
|
||||
```
|
||||
|
||||
290
db_handler.py
290
db_handler.py
@@ -1,8 +1,7 @@
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from utilities.utils import decimal_to_hex
|
||||
import default_config as config
|
||||
@@ -14,26 +13,22 @@ def get_table_name(channel):
|
||||
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
|
||||
return quoted_table_name
|
||||
|
||||
|
||||
def save_message_to_db(channel, user_id, message_text):
|
||||
"""Save messages to the database, ensuring the table exists."""
|
||||
try:
|
||||
quoted_table_name = get_table_name(channel)
|
||||
|
||||
schema = '''
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
'''
|
||||
ensure_table_exists(quoted_table_name, schema)
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
|
||||
quoted_table_name = get_table_name(channel)
|
||||
|
||||
# Ensure the table exists
|
||||
create_table_query = f'''
|
||||
CREATE TABLE IF NOT EXISTS {quoted_table_name} (
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
)
|
||||
'''
|
||||
|
||||
db_cursor.execute(create_table_query)
|
||||
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Insert the message
|
||||
@@ -48,10 +43,10 @@ def save_message_to_db(channel, user_id, message_text):
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in save_message_to_db: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in save_message_to_db: {e}")
|
||||
|
||||
|
||||
def update_ack_nak(channel, timestamp, message, ack):
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
@@ -74,15 +69,12 @@ def update_ack_nak(channel, timestamp, message, ack):
|
||||
logging.error(f"Unexpected error in update_ack_nak: {e}")
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
def load_messages_from_db():
|
||||
"""Load messages from the database for all channels and update globals.all_messages and globals.channel_list."""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
|
||||
# Retrieve all table names that match the pattern
|
||||
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
|
||||
db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
|
||||
tables = [row[0] for row in db_cursor.fetchall()]
|
||||
@@ -105,11 +97,11 @@ def load_messages_from_db():
|
||||
# Extract the channel name from the table name
|
||||
channel = table_name.split("_")[1]
|
||||
|
||||
# Convert the channel to an integer if it's numeric, otherwise keep it as a string
|
||||
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
|
||||
channel = int(channel) if channel.isdigit() else channel
|
||||
|
||||
# Add the channel to globals.channel_list if not already present
|
||||
if channel not in globals.channel_list:
|
||||
if channel not in globals.channel_list and not is_chat_archived(channel):
|
||||
globals.channel_list.append(channel)
|
||||
|
||||
# Ensure the channel exists in globals.all_messages
|
||||
@@ -152,140 +144,133 @@ def load_messages_from_db():
|
||||
|
||||
def init_nodedb():
|
||||
"""Initialize the node database and update it with nodes from the interface."""
|
||||
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
if not globals.interface.nodes:
|
||||
return # No nodes to initialize
|
||||
|
||||
# Table name construction
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote the table name because it might begin with numerics
|
||||
ensure_node_table_exists() # Ensure the table exists before insertion
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
|
||||
# Create the table if it doesn't exist
|
||||
create_table_query = f'''
|
||||
CREATE TABLE IF NOT EXISTS {nodeinfo_table} (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
long_name TEXT,
|
||||
short_name TEXT,
|
||||
hw_model TEXT,
|
||||
is_licensed TEXT,
|
||||
role TEXT,
|
||||
public_key TEXT
|
||||
)
|
||||
'''
|
||||
db_cursor.execute(create_table_query)
|
||||
# Insert or update all nodes
|
||||
for node in nodes_snapshot:
|
||||
update_node_info_in_db(
|
||||
user_id=node['num'],
|
||||
long_name=node['user'].get('longName', ''),
|
||||
short_name=node['user'].get('shortName', ''),
|
||||
hw_model=node['user'].get('hwModel', ''),
|
||||
is_licensed=node['user'].get('isLicensed', '0'),
|
||||
role=node['user'].get('role', 'CLIENT'),
|
||||
public_key=node['user'].get('publicKey', '')
|
||||
)
|
||||
|
||||
# Iterate over nodes and insert them into the database
|
||||
if globals.interface.nodes:
|
||||
for node in globals.interface.nodes.values():
|
||||
role = node['user'].get('role', 'CLIENT')
|
||||
is_licensed = node['user'].get('isLicensed', '0')
|
||||
public_key = node['user'].get('publicKey', '')
|
||||
|
||||
insert_query = f'''
|
||||
INSERT OR IGNORE INTO {nodeinfo_table} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
'''
|
||||
|
||||
db_cursor.execute(insert_query, (
|
||||
node['num'],
|
||||
node['user']['longName'],
|
||||
node['user']['shortName'],
|
||||
node['user']['hwModel'],
|
||||
is_licensed,
|
||||
role,
|
||||
public_key
|
||||
))
|
||||
|
||||
db_connection.commit()
|
||||
logging.info("Node database initialized successfully.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in init_nodedb: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in init_nodedb: {e}")
|
||||
|
||||
|
||||
def maybe_store_nodeinfo_in_db(packet):
|
||||
"""Save nodeinfo unless that record is already there."""
|
||||
"""Save nodeinfo unless that record is already there, updating if necessary."""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"' # Quote the table name becuase we might begin with numerics
|
||||
db_cursor = db_connection.cursor()
|
||||
|
||||
# Check if a record with the same user_id already exists
|
||||
existing_record = db_cursor.execute(f'SELECT * FROM {nodeinfo_table} WHERE user_id=?', (packet['from'],)).fetchone()
|
||||
|
||||
if existing_record is None:
|
||||
role = packet['decoded']['user'].get('role', 'CLIENT')
|
||||
is_licensed = packet['decoded']['user'].get('isLicensed', '0')
|
||||
public_key = packet['decoded']['user'].get('publicKey', '')
|
||||
|
||||
# No existing record, insert the new record
|
||||
insert_query = f'''
|
||||
INSERT INTO {nodeinfo_table} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
'''
|
||||
|
||||
db_cursor.execute(insert_query, (
|
||||
packet['from'],
|
||||
packet['decoded']['user']['longName'],
|
||||
packet['decoded']['user']['shortName'],
|
||||
packet['decoded']['user']['hwModel'],
|
||||
is_licensed,
|
||||
role,
|
||||
public_key
|
||||
))
|
||||
|
||||
db_connection.commit()
|
||||
|
||||
else:
|
||||
# Check if values are different, update if necessary
|
||||
# Extract existing values
|
||||
existing_long_name = existing_record[1]
|
||||
existing_short_name = existing_record[2]
|
||||
existing_is_licensed = existing_record[4]
|
||||
existing_role = existing_record[5]
|
||||
existing_public_key = existing_record[6]
|
||||
|
||||
# Extract new values from the packet
|
||||
new_long_name = packet['decoded']['user']['longName']
|
||||
new_short_name = packet['decoded']['user']['shortName']
|
||||
new_is_licensed = packet['decoded']['user'].get('isLicensed', '0')
|
||||
new_role = packet['decoded']['user'].get('role', 'CLIENT')
|
||||
new_public_key = packet['decoded']['user'].get('publicKey', '')
|
||||
|
||||
# Check for any differences
|
||||
if (
|
||||
existing_long_name != new_long_name or
|
||||
existing_short_name != new_short_name or
|
||||
existing_is_licensed != new_is_licensed or
|
||||
existing_role != new_role or
|
||||
existing_public_key != new_public_key
|
||||
):
|
||||
# Perform necessary updates
|
||||
update_query = f'''
|
||||
UPDATE {nodeinfo_table}
|
||||
SET long_name = ?, short_name = ?, is_licensed = ?, role = ?, public_key = ?
|
||||
WHERE user_id = ?
|
||||
'''
|
||||
db_cursor.execute(update_query, (
|
||||
new_long_name,
|
||||
new_short_name,
|
||||
new_is_licensed,
|
||||
new_role,
|
||||
new_public_key,
|
||||
packet['from']
|
||||
))
|
||||
|
||||
db_connection.commit()
|
||||
|
||||
# TODO display new node name in nodelist
|
||||
user_id = packet['from']
|
||||
long_name = packet['decoded']['user']['longName']
|
||||
short_name = packet['decoded']['user']['shortName']
|
||||
hw_model = packet['decoded']['user']['hwModel']
|
||||
is_licensed = packet['decoded']['user'].get('isLicensed', '0')
|
||||
role = packet['decoded']['user'].get('role', 'CLIENT')
|
||||
public_key = packet['decoded']['user'].get('publicKey', '')
|
||||
|
||||
update_node_info_in_db(user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in maybe_store_nodeinfo_in_db: {e}")
|
||||
finally:
|
||||
db_connection.close()
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}")
|
||||
|
||||
|
||||
def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=None, is_licensed=None, role=None, public_key=None, chat_archived=None):
|
||||
"""Update or insert node information into the database, preserving unchanged fields."""
|
||||
try:
|
||||
ensure_node_table_exists() # Ensure the table exists before any operation
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names
|
||||
|
||||
|
||||
table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({table_name})')]
|
||||
if "chat_archived" not in table_columns:
|
||||
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
|
||||
db_cursor.execute(update_table_query)
|
||||
|
||||
# Fetch existing values to preserve unchanged fields
|
||||
db_cursor.execute(f'SELECT * FROM {table_name} WHERE user_id = ?', (user_id,))
|
||||
existing_record = db_cursor.fetchone()
|
||||
|
||||
if existing_record:
|
||||
existing_long_name, existing_short_name, existing_hw_model, existing_is_licensed, existing_role, existing_public_key, existing_chat_archived = existing_record[1:]
|
||||
|
||||
long_name = long_name if long_name is not None else existing_long_name
|
||||
short_name = short_name if short_name is not None else existing_short_name
|
||||
hw_model = hw_model if hw_model is not None else existing_hw_model
|
||||
is_licensed = is_licensed if is_licensed is not None else existing_is_licensed
|
||||
role = role if role is not None else existing_role
|
||||
public_key = public_key if public_key is not None else existing_public_key
|
||||
chat_archived = chat_archived if chat_archived is not None else existing_chat_archived
|
||||
|
||||
# Upsert logic
|
||||
upsert_query = f'''
|
||||
INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
long_name = excluded.long_name,
|
||||
short_name = excluded.short_name,
|
||||
hw_model = excluded.hw_model,
|
||||
is_licensed = excluded.is_licensed,
|
||||
role = excluded.role,
|
||||
public_key = excluded.public_key,
|
||||
chat_archived = excluded.chat_archived
|
||||
'''
|
||||
db_cursor.execute(upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived))
|
||||
db_connection.commit()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in update_node_info_in_db: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in update_node_info_in_db: {e}")
|
||||
|
||||
|
||||
def ensure_node_table_exists():
|
||||
"""Ensure the node database table exists."""
|
||||
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote for safety
|
||||
schema = '''
|
||||
user_id TEXT PRIMARY KEY,
|
||||
long_name TEXT,
|
||||
short_name TEXT,
|
||||
hw_model TEXT,
|
||||
is_licensed TEXT,
|
||||
role TEXT,
|
||||
public_key TEXT,
|
||||
chat_archived INTEGER
|
||||
'''
|
||||
ensure_table_exists(table_name, schema)
|
||||
|
||||
|
||||
def ensure_table_exists(table_name, schema):
|
||||
"""Ensure the given table exists in the database."""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
create_table_query = f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})"
|
||||
db_cursor.execute(create_table_query)
|
||||
db_connection.commit()
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in ensure_table_exists({table_name}): {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in ensure_table_exists({table_name}): {e}")
|
||||
|
||||
|
||||
def get_name_from_database(user_id, type="long"):
|
||||
@@ -320,4 +305,25 @@ def get_name_from_database(user_id, type="long"):
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in get_name_from_database: {e}")
|
||||
return "Unknown"
|
||||
return "Unknown"
|
||||
|
||||
def is_chat_archived(user_id):
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
db_cursor = db_connection.cursor()
|
||||
table_name = f"{str(globals.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"'
|
||||
query = f"SELECT chat_archived FROM {nodeinfo_table} WHERE user_id = ?"
|
||||
db_cursor.execute(query, (user_id,))
|
||||
result = db_cursor.fetchone()
|
||||
|
||||
return result[0] if result else 0
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error in is_chat_archived: {e}")
|
||||
return "Unknown"
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error in is_chat_archived: {e}")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ def initialize_config():
|
||||
"ack_str": "[✓]",
|
||||
"nak_str": "[x]",
|
||||
"ack_unknown_str": "[…]",
|
||||
"node_sort": "lastHeard",
|
||||
"theme": "dark",
|
||||
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
|
||||
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
|
||||
@@ -148,6 +149,7 @@ def assign_config_variables(loaded_config):
|
||||
global db_file_path, log_file_path, message_prefix, sent_message_prefix
|
||||
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
|
||||
global theme, COLOR_CONFIG
|
||||
global node_sort
|
||||
|
||||
db_file_path = loaded_config["db_file_path"]
|
||||
log_file_path = loaded_config["log_file_path"]
|
||||
@@ -165,6 +167,7 @@ def assign_config_variables(loaded_config):
|
||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
|
||||
elif theme == "green":
|
||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
|
||||
node_sort = loaded_config["node_sort"]
|
||||
|
||||
|
||||
# Call the function when the script is imported
|
||||
|
||||
@@ -2,11 +2,12 @@ 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
|
||||
current_window = 0
|
||||
lock = None
|
||||
@@ -2,7 +2,7 @@ import curses
|
||||
import ipaddress
|
||||
from ui.colors import get_color
|
||||
|
||||
def get_user_input(prompt):
|
||||
def get_text_input(prompt):
|
||||
# Calculate the dynamic height and width for the input window
|
||||
height = 7 # Fixed height for input prompt
|
||||
width = 60
|
||||
@@ -54,51 +54,6 @@ def get_user_input(prompt):
|
||||
input_win.refresh()
|
||||
return user_input
|
||||
|
||||
def get_bool_selection(message, current_value):
|
||||
message = "Select True or False:" if None else message
|
||||
cvalue = current_value
|
||||
options = ["True", "False"]
|
||||
selected_index = 0 if current_value == "True" else 1
|
||||
|
||||
height = 7
|
||||
width = 60
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
bool_win = curses.newwin(height, width, start_y, start_x)
|
||||
bool_win.bkgd(get_color("background"))
|
||||
bool_win.attrset(get_color("window_frame"))
|
||||
bool_win.keypad(True)
|
||||
bool_win.erase()
|
||||
|
||||
bool_win.border()
|
||||
bool_win.addstr(1, 2, message, get_color("settings_default", bold=True))
|
||||
|
||||
for idx, option in enumerate(options):
|
||||
if idx == selected_index:
|
||||
bool_win.addstr(idx + 3, 4, option, get_color("settings_default", reverse=True))
|
||||
else:
|
||||
bool_win.addstr(idx + 3, 4, option, get_color("settings_default"))
|
||||
|
||||
bool_win.refresh()
|
||||
|
||||
while True:
|
||||
key = bool_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
if(selected_index > 0):
|
||||
selected_index = selected_index - 1
|
||||
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default"))
|
||||
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default", reverse = True))
|
||||
elif key == curses.KEY_DOWN:
|
||||
if(selected_index < len(options) - 1):
|
||||
selected_index = selected_index + 1
|
||||
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default"))
|
||||
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default", reverse = True))
|
||||
elif key == ord('\n'): # Enter key
|
||||
return options[selected_index]
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
return cvalue
|
||||
|
||||
def get_repeated_input(current_value):
|
||||
cvalue = current_value
|
||||
@@ -142,69 +97,6 @@ def get_repeated_input(current_value):
|
||||
except ValueError:
|
||||
pass # Ignore invalid character inputs
|
||||
|
||||
def move_highlight(old_idx, new_idx, options, enum_win, enum_pad):
|
||||
if old_idx == new_idx:
|
||||
return # no-op
|
||||
|
||||
enum_pad.chgat(old_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default"))
|
||||
enum_pad.chgat(new_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
|
||||
|
||||
enum_win.refresh()
|
||||
|
||||
start_index = max(0, new_idx - (enum_win.getmaxyx()[0] - 4))
|
||||
|
||||
enum_win.refresh()
|
||||
enum_pad.refresh(start_index, 0,
|
||||
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
|
||||
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + 4 + enum_win.getmaxyx()[1] - 4)
|
||||
|
||||
def get_enum_input(options, current_value):
|
||||
selected_index = options.index(current_value) if current_value in options else 0
|
||||
|
||||
height = min(len(options) + 4, curses.LINES - 2)
|
||||
width = 60
|
||||
start_y = (curses.LINES - height) // 2
|
||||
start_x = (curses.COLS - width) // 2
|
||||
|
||||
enum_win = curses.newwin(height, width, start_y, start_x)
|
||||
enum_win.bkgd(get_color("background"))
|
||||
enum_win.attrset(get_color("window_frame"))
|
||||
enum_win.keypad(True)
|
||||
|
||||
enum_pad = curses.newpad(len(options) + 1, width - 8)
|
||||
enum_pad.bkgd(get_color("background"))
|
||||
|
||||
enum_win.erase()
|
||||
enum_win.border()
|
||||
enum_win.addstr(1, 2, "Select an option:", get_color("settings_default", bold=True))
|
||||
|
||||
for idx, option in enumerate(options):
|
||||
if idx == selected_index:
|
||||
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default", reverse=True))
|
||||
else:
|
||||
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default"))
|
||||
|
||||
enum_win.refresh()
|
||||
enum_pad.refresh(0, 0,
|
||||
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
|
||||
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + enum_win.getmaxyx()[1] - 4)
|
||||
|
||||
while True:
|
||||
key = enum_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
old_selected_index = selected_index
|
||||
selected_index = max(0, selected_index - 1)
|
||||
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
|
||||
elif key == curses.KEY_DOWN:
|
||||
old_selected_index = selected_index
|
||||
selected_index = min(len(options) - 1, selected_index + 1)
|
||||
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
|
||||
elif key == ord('\n'): # Enter key
|
||||
return options[selected_index]
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
return current_value
|
||||
|
||||
|
||||
def get_fixed32_input(current_value):
|
||||
cvalue = current_value
|
||||
@@ -261,10 +153,9 @@ def get_fixed32_input(current_value):
|
||||
pass # Ignore invalid inputs
|
||||
|
||||
|
||||
|
||||
def select_from_list(prompt, current_option, list_options):
|
||||
def get_list_input(prompt, current_option, list_options):
|
||||
"""
|
||||
Displays a scrollable list of list_options for the user to choose from using a pad.
|
||||
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
|
||||
|
||||
@@ -282,7 +173,7 @@ def select_from_list(prompt, current_option, list_options):
|
||||
list_pad.bkgd(get_color("background"))
|
||||
|
||||
# Render header
|
||||
list_win.clear()
|
||||
list_win.erase()
|
||||
list_win.border()
|
||||
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
|
||||
|
||||
@@ -303,28 +194,32 @@ def select_from_list(prompt, current_option, list_options):
|
||||
key = list_win.getch()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
|
||||
if selected_index > 0:
|
||||
selected_index -= 1
|
||||
|
||||
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:
|
||||
if selected_index < len(list_options) - 1:
|
||||
selected_index += 1
|
||||
|
||||
elif key == curses.KEY_RIGHT or key == ord('\n'):
|
||||
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 == curses.KEY_LEFT or key == 27: # ESC key
|
||||
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
|
||||
return current_option
|
||||
|
||||
# Refresh the pad with updated selection and scroll offset
|
||||
for idx, color in enumerate(list_options):
|
||||
if idx == selected_index:
|
||||
list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True))
|
||||
else:
|
||||
list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
|
||||
|
||||
list_win.refresh()
|
||||
list_pad.refresh(0, 0,
|
||||
list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4,
|
||||
list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4)
|
||||
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)
|
||||
|
||||
41
main.py
41
main.py
@@ -3,7 +3,7 @@
|
||||
'''
|
||||
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
|
||||
Powered by Meshtastic.org
|
||||
V 1.2.0
|
||||
V 1.2.1
|
||||
'''
|
||||
|
||||
import curses
|
||||
@@ -11,20 +11,27 @@ 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 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
|
||||
@@ -34,6 +41,8 @@ logging.basicConfig(
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
globals.lock = threading.Lock()
|
||||
|
||||
def main(stdscr):
|
||||
try:
|
||||
draw_splash(stdscr)
|
||||
@@ -41,15 +50,23 @@ def main(stdscr):
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.info("Initializing interface %s", args)
|
||||
globals.interface = initialize_interface(args)
|
||||
logging.info("Interface initialized")
|
||||
globals.myNodeNum = get_nodeNum()
|
||||
globals.channel_list = get_channels()
|
||||
globals.node_list = get_node_list()
|
||||
pub.subscribe(on_receive, 'meshtastic.receive')
|
||||
init_nodedb()
|
||||
load_messages_from_db()
|
||||
logging.info("Starting main UI")
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
import time
|
||||
from utilities.utils import get_node_list
|
||||
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, get_name_from_database
|
||||
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
|
||||
|
||||
@@ -11,92 +12,94 @@ from datetime import datetime
|
||||
|
||||
def on_receive(packet, interface):
|
||||
|
||||
# Update packet log
|
||||
globals.packet_buffer.append(packet)
|
||||
if len(globals.packet_buffer) > 20:
|
||||
# Trim buffer to 20 packets
|
||||
globals.packet_buffer = globals.packet_buffer[-20:]
|
||||
|
||||
if globals.display_log:
|
||||
draw_packetlog_win()
|
||||
try:
|
||||
if 'decoded' not in packet:
|
||||
return
|
||||
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
|
||||
new_node_list = get_node_list()
|
||||
if new_node_list != globals.node_list:
|
||||
globals.node_list = new_node_list
|
||||
draw_node_list()
|
||||
# Assume any incoming packet could update the last seen time for a node
|
||||
changed = refresh_node_list()
|
||||
if(changed):
|
||||
draw_node_list()
|
||||
|
||||
if packet['decoded']['portnum'] == 'NODEINFO_APP':
|
||||
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
|
||||
maybe_store_nodeinfo_in_db(packet)
|
||||
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')
|
||||
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
|
||||
refresh_channels = False
|
||||
refresh_messages = False
|
||||
|
||||
if packet.get('channel'):
|
||||
channel_number = packet['channel']
|
||||
else:
|
||||
channel_number = 0
|
||||
|
||||
if packet['to'] == globals.myNodeNum:
|
||||
if packet['from'] in globals.channel_list:
|
||||
pass
|
||||
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
|
||||
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
# 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] != globals.channel_list[globals.selected_channel]:
|
||||
add_notification(channel_number)
|
||||
refresh_channels = True
|
||||
else:
|
||||
refresh_messages = True
|
||||
if globals.channel_list[channel_number] not in globals.all_messages:
|
||||
globals.all_messages[globals.channel_list[channel_number]] = []
|
||||
|
||||
# Add received message to the messages list
|
||||
message_from_id = packet['from']
|
||||
message_from_string = get_name_from_database(message_from_id, type='short') + ":"
|
||||
# Timestamp handling
|
||||
current_timestamp = time.time()
|
||||
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
|
||||
|
||||
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
|
||||
# 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
|
||||
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} --", ""))
|
||||
# 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))
|
||||
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)
|
||||
if refresh_channels:
|
||||
draw_channel_list()
|
||||
if refresh_messages:
|
||||
draw_messages_window(True)
|
||||
|
||||
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
|
||||
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}")
|
||||
except KeyError as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
|
||||
@@ -3,7 +3,7 @@ import google.protobuf.json_format
|
||||
from meshtastic import BROADCAST_NUM
|
||||
from meshtastic.protobuf import mesh_pb2, portnums_pb2
|
||||
|
||||
from db_handler import save_message_to_db, update_ack_nak, get_name_from_database
|
||||
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
|
||||
|
||||
@@ -94,6 +94,9 @@ def on_response_traceroute(packet):
|
||||
globals.channel_list.append(packet['from'])
|
||||
refresh_channels = True
|
||||
|
||||
if(is_chat_archived(packet['from'])):
|
||||
update_node_info_in_db(packet['from'], chat_archived=False)
|
||||
|
||||
channel_number = globals.channel_list.index(packet['from'])
|
||||
|
||||
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
|
||||
|
||||
@@ -2,24 +2,8 @@ from meshtastic.protobuf import channel_pb2
|
||||
from google.protobuf.message import Message
|
||||
import logging
|
||||
import base64
|
||||
|
||||
def settings_reboot(interface):
|
||||
interface.localNode.reboot()
|
||||
|
||||
def settings_reset_nodedb(interface):
|
||||
interface.localNode.resetNodeDb()
|
||||
|
||||
def settings_shutdown(interface):
|
||||
interface.localNode.shutdown()
|
||||
|
||||
def settings_factory_reset(interface):
|
||||
interface.localNode.factoryReset()
|
||||
|
||||
# def settings_set_owner(interface, long_name=None, short_name=None, is_licensed=False):
|
||||
# if isinstance(is_licensed, str):
|
||||
# is_licensed = is_licensed.lower() == 'true'
|
||||
# interface.localNode.setOwner(long_name, short_name, is_licensed)
|
||||
|
||||
from db_handler import update_node_info_in_db
|
||||
import globals
|
||||
|
||||
def save_changes(interface, menu_path, modified_settings):
|
||||
"""
|
||||
@@ -47,16 +31,20 @@ def save_changes(interface, menu_path, modified_settings):
|
||||
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
||||
return
|
||||
|
||||
elif menu_path[1] == "User Settings": # for user configs
|
||||
config_category = "User Settings"
|
||||
elif menu_path[1] == "User Settings": # for user configs
|
||||
config_category = "User Settings"
|
||||
long_name = modified_settings.get("longName")
|
||||
short_name = modified_settings.get("shortName")
|
||||
is_licensed = modified_settings.get("isLicensed")
|
||||
is_licensed = is_licensed == "True" or is_licensed is True
|
||||
is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed)
|
||||
|
||||
logging.info(f"Updated {config_category} with Long Name: {long_name} and Short Name {short_name} and Licensed Mode {is_licensed}")
|
||||
# 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
|
||||
|
||||
83
settings.py
83
settings.py
@@ -2,13 +2,14 @@ import curses
|
||||
import logging
|
||||
import os
|
||||
|
||||
from save_to_radio import settings_factory_reset, settings_reboot, settings_reset_nodedb, settings_shutdown, save_changes
|
||||
from save_to_radio import save_changes
|
||||
from utilities.config_io import config_export, config_import
|
||||
from input_handlers import get_bool_selection, get_repeated_input, get_user_input, get_enum_input, get_fixed32_input, select_from_list
|
||||
from input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input
|
||||
from ui.menus import generate_menu_from_protobuf
|
||||
from ui.colors import setup_colors, get_color
|
||||
from utilities.arg_parser import setup_parser
|
||||
from utilities.interfaces import initialize_interface
|
||||
from ui.dialog import dialog
|
||||
from user_config import json_editor
|
||||
import globals
|
||||
|
||||
@@ -171,7 +172,7 @@ def settings_menu(stdscr, interface):
|
||||
|
||||
|
||||
elif selected_option == "Export Config":
|
||||
filename = get_user_input("Enter a filename for the config file")
|
||||
filename = get_text_input("Enter a filename for the config file")
|
||||
|
||||
if not filename:
|
||||
logging.warning("Export aborted: No filename provided.")
|
||||
@@ -187,8 +188,8 @@ def settings_menu(stdscr, interface):
|
||||
yaml_file_path = os.path.join(app_directory, config_folder, filename)
|
||||
|
||||
if os.path.exists(yaml_file_path):
|
||||
overwrite = get_bool_selection(f"{filename} already exists. Overwrite?", None)
|
||||
if overwrite == "False":
|
||||
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
|
||||
if overwrite == "Yes":
|
||||
logging.info("Export cancelled: User chose not to overwrite.")
|
||||
continue # Return to menu
|
||||
os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True)
|
||||
@@ -204,51 +205,45 @@ def settings_menu(stdscr, interface):
|
||||
logging.error(f"Unexpected error: {e}")
|
||||
continue
|
||||
|
||||
|
||||
|
||||
|
||||
elif selected_option == "Load Config":
|
||||
|
||||
app_directory = os.path.dirname(os.path.abspath(__file__))
|
||||
config_folder = "node-configs"
|
||||
folder_path = os.path.join(app_directory, config_folder)
|
||||
file_list = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]
|
||||
filename = select_from_list("Choose a config file", None, file_list)
|
||||
filename = get_list_input("Choose a config file", None, file_list)
|
||||
if filename:
|
||||
file_path = os.path.join(app_directory, config_folder, filename)
|
||||
overwrite = get_bool_selection(f"Are you sure you want to load {filename}?", None)
|
||||
if overwrite == "True":
|
||||
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
|
||||
if overwrite == "Yes":
|
||||
config_import(globals.interface, file_path)
|
||||
break
|
||||
continue
|
||||
|
||||
|
||||
|
||||
elif selected_option == "Reboot":
|
||||
confirmation = get_bool_selection("Are you sure you want to Reboot?", 0)
|
||||
if confirmation == "True":
|
||||
settings_reboot(interface)
|
||||
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.reboot()
|
||||
logging.info(f"Node Reboot Requested by menu")
|
||||
break
|
||||
continue
|
||||
elif selected_option == "Reset Node DB":
|
||||
confirmation = get_bool_selection("Are you sure you want to Reset Node DB?", 0)
|
||||
if confirmation == "True":
|
||||
settings_reset_nodedb(interface)
|
||||
confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.resetNodeDb()
|
||||
logging.info(f"Node DB Reset Requested by menu")
|
||||
break
|
||||
continue
|
||||
elif selected_option == "Shutdown":
|
||||
confirmation = get_bool_selection("Are you sure you want to Shutdown?", 0)
|
||||
if confirmation == "True":
|
||||
settings_shutdown(interface)
|
||||
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.shutdown()
|
||||
logging.info(f"Node Shutdown Requested by menu")
|
||||
break
|
||||
continue
|
||||
elif selected_option == "Factory Reset":
|
||||
confirmation = get_bool_selection("Are you sure you want to Factory Reset?", 0)
|
||||
if confirmation == "True":
|
||||
settings_factory_reset(interface)
|
||||
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.factoryReset()
|
||||
logging.info(f"Factory Reset Requested by menu")
|
||||
break
|
||||
continue
|
||||
@@ -259,19 +254,18 @@ def settings_menu(stdscr, interface):
|
||||
continue
|
||||
# need_redraw = True
|
||||
|
||||
|
||||
field_info = current_menu.get(selected_option)
|
||||
if isinstance(field_info, tuple):
|
||||
field, current_value = field_info
|
||||
|
||||
if selected_option in ['longName', 'shortName', 'isLicensed']:
|
||||
if selected_option in ['longName', 'shortName']:
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
current_menu[selected_option] = (field, new_value)
|
||||
|
||||
elif selected_option == 'isLicensed':
|
||||
new_value = get_bool_selection(f"Current value for {selected_option}: {current_value}", str(current_value))
|
||||
new_value = get_list_input(f"Current value for {selected_option}: {current_value}", str(current_value), ["True", "False"])
|
||||
new_value = new_value == "True"
|
||||
current_menu[selected_option] = (field, new_value)
|
||||
|
||||
@@ -279,7 +273,7 @@ def settings_menu(stdscr, interface):
|
||||
modified_settings[option] = value
|
||||
|
||||
elif selected_option in ['latitude', 'longitude', 'altitude']:
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
current_menu[selected_option] = (field, new_value)
|
||||
|
||||
@@ -288,7 +282,7 @@ def settings_menu(stdscr, interface):
|
||||
modified_settings[option] = current_menu[option][1]
|
||||
|
||||
elif field.type == 8: # Handle boolean type
|
||||
new_value = get_bool_selection(selected_option, str(current_value))
|
||||
new_value = get_list_input(selected_option, str(current_value), ["True", "False"])
|
||||
new_value = new_value == "True" or new_value is True
|
||||
|
||||
elif field.label == field.LABEL_REPEATED: # Handle repeated field
|
||||
@@ -297,22 +291,22 @@ def settings_menu(stdscr, interface):
|
||||
|
||||
elif field.enum_type: # Enum field
|
||||
enum_options = {v.name: v.number for v in field.enum_type.values}
|
||||
new_value_name = get_enum_input(list(enum_options.keys()), current_value)
|
||||
new_value_name = get_list_input(selected_option, current_value, list(enum_options.keys()))
|
||||
new_value = enum_options.get(new_value_name, current_value)
|
||||
|
||||
elif field.type == 7: # Field type 7 corresponds to FIXED32
|
||||
new_value = get_fixed32_input(current_value)
|
||||
|
||||
elif field.type == 13: # Field type 13 corresponds to UINT32
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else int(new_value)
|
||||
|
||||
elif field.type == 2: # Field type 13 corresponds to INT64
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else float(new_value)
|
||||
|
||||
else: # Handle other field types
|
||||
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
|
||||
new_value = current_value if new_value is None else new_value
|
||||
|
||||
for key in menu_path[3:]: # Skip "Main Menu"
|
||||
@@ -355,6 +349,25 @@ def settings_menu(stdscr, interface):
|
||||
menu_win.refresh()
|
||||
break
|
||||
|
||||
def set_region():
|
||||
node = globals.interface.getNode('^local')
|
||||
device_config = node.localConfig
|
||||
lora_descriptor = device_config.lora.DESCRIPTOR
|
||||
|
||||
# Get the enum mapping of region names to their numerical values
|
||||
region_enum = lora_descriptor.fields_by_name["region"].enum_type
|
||||
region_name_to_number = {v.name: v.number for v in region_enum.values}
|
||||
|
||||
regions = list(region_name_to_number.keys())
|
||||
|
||||
new_region_name = get_list_input('Select your region:', 'UNSET', regions)
|
||||
|
||||
# Convert region name to corresponding enum number
|
||||
new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found
|
||||
|
||||
node.localConfig.lora.region = new_region_number
|
||||
node.writeConfig("lora")
|
||||
|
||||
|
||||
def main(stdscr):
|
||||
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
|
||||
|
||||
898
ui/curses_ui.py
898
ui/curses_ui.py
@@ -1,136 +1,310 @@
|
||||
import curses
|
||||
import textwrap
|
||||
from utilities.utils import get_channels, get_time_ago
|
||||
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
|
||||
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
|
||||
|
||||
def draw_node_details():
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
def handle_resize(stdscr, firstrun):
|
||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win
|
||||
|
||||
node = None
|
||||
for node in nodes_snapshot:
|
||||
if globals.node_list[globals.selected_node] == node['num']:
|
||||
# Calculate window max dimensions
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Define window dimensions and positions
|
||||
channel_width = 3 * (width // 16)
|
||||
nodes_width = 5 * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
|
||||
if firstrun:
|
||||
entry_win = curses.newwin(3, width, 0, 0)
|
||||
channel_win = curses.newwin(height - 6, channel_width, 3, 0)
|
||||
messages_win = curses.newwin(height - 6, messages_width, 3, channel_width)
|
||||
nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
|
||||
function_win = curses.newwin(3, width, height - 3, 0)
|
||||
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
|
||||
|
||||
# Will be resized to what we need when drawn
|
||||
messages_pad = curses.newpad(1,1)
|
||||
nodes_pad = curses.newpad(1,1)
|
||||
channel_pad = curses.newpad(1,1)
|
||||
|
||||
# Set background colors for windows
|
||||
for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]:
|
||||
win.bkgd(get_color("background"))
|
||||
|
||||
# Set background colors for pads
|
||||
for pad in [messages_pad, nodes_pad, channel_pad]:
|
||||
pad.bkgd(get_color("background"))
|
||||
|
||||
# Set colors for window frames
|
||||
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
|
||||
win.attrset(get_color("window_frame"))
|
||||
|
||||
|
||||
else:
|
||||
for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]:
|
||||
win.erase()
|
||||
|
||||
entry_win.resize(3, width)
|
||||
|
||||
channel_win.resize(height - 6, channel_width)
|
||||
|
||||
messages_win.resize(height - 6, messages_width)
|
||||
messages_win.mvwin(3, channel_width)
|
||||
|
||||
nodes_win.resize(height - 6, nodes_width)
|
||||
nodes_win.mvwin(3, channel_width + messages_width)
|
||||
|
||||
function_win.resize(3, width)
|
||||
function_win.mvwin(height - 3, 0)
|
||||
|
||||
packetlog_win.resize(int(height / 3), messages_width)
|
||||
packetlog_win.mvwin(height - int(height / 3) - 3, channel_width)
|
||||
|
||||
|
||||
# Draw window borders
|
||||
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
|
||||
win.box()
|
||||
win.refresh()
|
||||
|
||||
|
||||
entry_win.keypad(True)
|
||||
curses.curs_set(1)
|
||||
|
||||
try:
|
||||
draw_function_win()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
draw_node_list()
|
||||
except:
|
||||
# Resize events can come faster than we can re-draw, which can cause a curses error.
|
||||
# In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later.
|
||||
pass
|
||||
|
||||
|
||||
def main_ui(stdscr):
|
||||
|
||||
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
|
||||
|
||||
function_win.erase()
|
||||
function_win.box()
|
||||
# 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)
|
||||
|
||||
nodestr = ""
|
||||
width = function_win.getmaxyx()[1]
|
||||
node_details_list = [f"{node['user']['longName']}"
|
||||
if 'user' in node and 'longName' in node['user'] else "",
|
||||
f"({node['user']['shortName']})"
|
||||
if 'user' in node and 'shortName' in node['user'] else "",
|
||||
f" | {node['user']['hwModel']}"
|
||||
if 'user' in node and 'hwModel' in node['user'] else "",
|
||||
f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "",
|
||||
f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "",
|
||||
f" | SNR: {node['snr']}dB"
|
||||
if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0)
|
||||
else "",
|
||||
]
|
||||
|
||||
for s in node_details_list:
|
||||
if len(nodestr) + len(s) < width:
|
||||
nodestr = nodestr + s
|
||||
|
||||
draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
|
||||
|
||||
def draw_function_win():
|
||||
cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit"]
|
||||
function_str = ""
|
||||
for s in cmds:
|
||||
if(len(function_str) + len(s) < function_win.getmaxyx()[1]):
|
||||
function_str += s
|
||||
|
||||
draw_centered_text_field(function_win, function_str, 0, get_color("commands"))
|
||||
|
||||
def get_msg_window_lines():
|
||||
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if globals.display_log else 0
|
||||
return messages_box.getmaxyx()[0] - 2 - packetlog_height
|
||||
|
||||
def refresh_pad(window):
|
||||
win_height = channel_box.getmaxyx()[0]
|
||||
|
||||
selected_item = globals.selected_channel
|
||||
pad = channel_pad
|
||||
box = channel_box
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
if(window == 1):
|
||||
pad = messages_pad
|
||||
box = messages_box
|
||||
lines = get_msg_window_lines()
|
||||
selected_item = globals.selected_message
|
||||
start_index = globals.selected_message
|
||||
|
||||
if globals.display_log:
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
|
||||
if(window == 2):
|
||||
pad = nodes_pad
|
||||
box = nodes_box
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
selected_item = globals.selected_node
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
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]] = []
|
||||
|
||||
|
||||
pad.refresh(start_index, 0,
|
||||
box.getbegyx()[0] + 1, box.getbegyx()[1] + 1,
|
||||
box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2)
|
||||
globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node])
|
||||
|
||||
def highlight_line(highlight, window, line):
|
||||
pad = channel_pad
|
||||
select_len = 0
|
||||
ch_color = get_color("channel_list")
|
||||
nd_color = get_color("node_list")
|
||||
if(is_chat_archived(globals.channel_list[globals.selected_channel])):
|
||||
update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=False)
|
||||
|
||||
if(window == 2):
|
||||
pad = nodes_pad
|
||||
select_len = len(get_name_from_database(globals.node_list[line], "long"))
|
||||
globals.selected_node = 0
|
||||
globals.current_window = 0
|
||||
|
||||
pad.chgat(line, 1, select_len, nd_color | curses.A_REVERSE if highlight else nd_color)
|
||||
draw_node_list()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
|
||||
if(window == 0):
|
||||
channel = list(globals.all_messages.keys())[line]
|
||||
win_width = channel_box.getmaxyx()[1]
|
||||
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)
|
||||
|
||||
if(isinstance(channel, int)):
|
||||
channel = get_name_from_database(channel, type="long")
|
||||
select_len = min(len(channel), win_width - 4)
|
||||
# Clear entry window and reset input text
|
||||
input_text = ""
|
||||
entry_win.erase()
|
||||
|
||||
if line == globals.selected_channel and highlight == False:
|
||||
ch_color = get_color("channel_selected")
|
||||
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)
|
||||
|
||||
pad.chgat(line, 1, select_len, ch_color | curses.A_REVERSE if highlight else ch_color)
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
def add_notification(channel_number):
|
||||
globals.notifications.add(channel_number)
|
||||
# ^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)
|
||||
|
||||
def remove_notification(channel_number):
|
||||
globals.notifications.discard(channel_number)
|
||||
# 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
|
||||
|
||||
def draw_text_field(win, text, color):
|
||||
win.border()
|
||||
win.addstr(1, 1, text, color)
|
||||
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()
|
||||
|
||||
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()
|
||||
elif char == chr(31):
|
||||
if(globals.current_window == 2 or globals.current_window == 0):
|
||||
search(globals.current_window)
|
||||
|
||||
def draw_debug(value):
|
||||
function_win.addstr(1, 1, f"debug: {value} ")
|
||||
function_win.refresh()
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if(isinstance(char, str)):
|
||||
input_text += char
|
||||
else:
|
||||
input_text += chr(char)
|
||||
|
||||
def draw_splash(stdscr):
|
||||
setup_colors()
|
||||
@@ -161,34 +335,39 @@ def draw_splash(stdscr):
|
||||
|
||||
def draw_channel_list():
|
||||
channel_pad.erase()
|
||||
win_height, win_width = channel_box.getmaxyx()
|
||||
win_height, win_width = channel_win.getmaxyx()
|
||||
start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders
|
||||
|
||||
channel_pad.resize(len(globals.all_messages), channel_box.getmaxyx()[1])
|
||||
channel_pad.resize(len(globals.all_messages), channel_win.getmaxyx()[1])
|
||||
|
||||
for i, channel in enumerate(list(globals.all_messages.keys())):
|
||||
idx = 0
|
||||
for channel in globals.channel_list:
|
||||
# Convert node number to long name if it's an integer
|
||||
if isinstance(channel, int):
|
||||
if is_chat_archived(channel):
|
||||
continue
|
||||
channel = get_name_from_database(channel, type='long')
|
||||
|
||||
# Determine whether to add the notification
|
||||
notification = " " + config.notification_symbol if 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 == globals.selected_channel:
|
||||
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:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list", reverse=True))
|
||||
color = get_color("channel_list", reverse=True)
|
||||
remove_notification(globals.selected_channel)
|
||||
else:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_selected"))
|
||||
else:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list"))
|
||||
color = get_color("channel_selected")
|
||||
channel_pad.addstr(idx, 1, truncated_channel, color)
|
||||
idx += 1
|
||||
|
||||
channel_box.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame"))
|
||||
channel_box.box()
|
||||
channel_box.attrset((get_color("window_frame")))
|
||||
channel_box.refresh()
|
||||
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)
|
||||
|
||||
@@ -206,9 +385,9 @@ def draw_messages_window(scroll_to_bottom = False):
|
||||
row = 0
|
||||
for (prefix, message) in messages:
|
||||
full_message = f"{prefix}{message}"
|
||||
wrapped_lines = textwrap.wrap(full_message, messages_box.getmaxyx()[1] - 2)
|
||||
wrapped_lines = textwrap.wrap(full_message, messages_win.getmaxyx()[1] - 2)
|
||||
msg_line_count += len(wrapped_lines)
|
||||
messages_pad.resize(msg_line_count, messages_box.getmaxyx()[1])
|
||||
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
||||
|
||||
for line in wrapped_lines:
|
||||
if prefix.startswith("--"):
|
||||
@@ -221,10 +400,10 @@ def draw_messages_window(scroll_to_bottom = False):
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
row += 1
|
||||
|
||||
messages_box.attrset(get_color("window_frame_selected") if globals.current_window == 1 else get_color("window_frame"))
|
||||
messages_box.box()
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
messages_box.refresh()
|
||||
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)
|
||||
@@ -236,19 +415,32 @@ def draw_messages_window(scroll_to_bottom = False):
|
||||
draw_packetlog_win()
|
||||
|
||||
def draw_node_list():
|
||||
nodes_pad.erase()
|
||||
nodes_pad.resize(len(globals.node_list), nodes_box.getmaxyx()[1])
|
||||
global nodes_pad
|
||||
|
||||
for i, node in enumerate(globals.node_list):
|
||||
if globals.selected_node == i and globals.current_window == 2:
|
||||
nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list", reverse=True))
|
||||
else:
|
||||
nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list"))
|
||||
# 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)
|
||||
|
||||
nodes_box.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame"))
|
||||
nodes_box.box()
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
nodes_box.refresh()
|
||||
|
||||
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)
|
||||
|
||||
@@ -292,7 +484,7 @@ def select_node(idx):
|
||||
highlight_line(True, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
draw_node_details()
|
||||
draw_function_win()
|
||||
|
||||
def scroll_nodes(direction):
|
||||
new_selected_node = globals.selected_node + direction
|
||||
@@ -348,283 +540,181 @@ def draw_packetlog_win():
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
|
||||
def handle_resize(stdscr, firstrun):
|
||||
global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win
|
||||
def search(win):
|
||||
start_idx = globals.selected_node
|
||||
select_func = select_node
|
||||
|
||||
# Calculate window max dimensions
|
||||
height, width = stdscr.getmaxyx()
|
||||
if win == 0:
|
||||
start_idx = globals.selected_channel
|
||||
select_func = select_channel
|
||||
|
||||
# Define window dimensions and positions
|
||||
channel_width = 3 * (width // 16)
|
||||
nodes_width = 5 * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
|
||||
if firstrun:
|
||||
entry_win = curses.newwin(3, width, 0, 0)
|
||||
channel_box = curses.newwin(height - 6, channel_width, 3, 0)
|
||||
messages_box = curses.newwin(height - 6, messages_width, 3, channel_width)
|
||||
nodes_box = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
|
||||
function_win = curses.newwin(3, width, height - 3, 0)
|
||||
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
|
||||
|
||||
# Will be resized to what we need when drawn
|
||||
messages_pad = curses.newpad(1, 1)
|
||||
nodes_pad = curses.newpad(1,1)
|
||||
channel_pad = curses.newpad(1,1)
|
||||
|
||||
entry_win.bkgd(get_color("background"))
|
||||
channel_box.bkgd(get_color("background"))
|
||||
messages_box.bkgd(get_color("background"))
|
||||
nodes_box.bkgd(get_color("background"))
|
||||
|
||||
messages_pad.bkgd(get_color("background"))
|
||||
nodes_pad.bkgd(get_color("background"))
|
||||
channel_pad.bkgd(get_color("background"))
|
||||
|
||||
function_win.bkgd(get_color("background"))
|
||||
packetlog_win.bkgd(get_color("background"))
|
||||
|
||||
channel_box.attrset(get_color("window_frame"))
|
||||
entry_win.attrset(get_color("window_frame"))
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
function_win.attrset(get_color("window_frame"))
|
||||
|
||||
else:
|
||||
entry_win.erase()
|
||||
channel_box.erase()
|
||||
messages_box.erase()
|
||||
nodes_box.erase()
|
||||
function_win.erase()
|
||||
packetlog_win.erase()
|
||||
entry_win.resize(3, width)
|
||||
channel_box.resize(height - 6, channel_width)
|
||||
messages_box.resize(height - 6, messages_width)
|
||||
messages_box.mvwin(3, channel_width)
|
||||
nodes_box.resize(height - 6, nodes_width)
|
||||
nodes_box.mvwin(3, channel_width + messages_width)
|
||||
function_win.resize(3, width)
|
||||
function_win.mvwin(height - 3, 0)
|
||||
packetlog_win.resize(int(height / 3), messages_width)
|
||||
packetlog_win.mvwin(height - int(height / 3) - 3, channel_width)
|
||||
|
||||
|
||||
channel_box.box()
|
||||
entry_win.box()
|
||||
nodes_box.box()
|
||||
messages_box.box()
|
||||
|
||||
function_win.box()
|
||||
|
||||
# Refresh all windows
|
||||
entry_win.refresh()
|
||||
channel_box.refresh()
|
||||
function_win.refresh()
|
||||
nodes_box.refresh()
|
||||
messages_box.refresh()
|
||||
|
||||
entry_win.keypad(True)
|
||||
curses.curs_set(1)
|
||||
|
||||
try:
|
||||
draw_function_win()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
draw_node_list()
|
||||
except:
|
||||
# Resize events can come faster than we can re-draw, which can cause a curses error.
|
||||
# In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later.
|
||||
pass
|
||||
|
||||
def main_ui(stdscr):
|
||||
global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win
|
||||
messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None
|
||||
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
|
||||
input_text = ""
|
||||
|
||||
handle_resize(stdscr, True)
|
||||
search_text = ""
|
||||
entry_win.erase()
|
||||
|
||||
while True:
|
||||
draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
|
||||
# Get user input from entry window
|
||||
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
|
||||
char = entry_win.get_wch()
|
||||
|
||||
# draw_debug(f"Keypress: {char}")
|
||||
|
||||
if char == curses.KEY_UP:
|
||||
if globals.current_window == 0:
|
||||
scroll_channels(-1)
|
||||
elif globals.current_window == 1:
|
||||
scroll_messages(-1)
|
||||
elif globals.current_window == 2:
|
||||
scroll_nodes(-1)
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char == curses.KEY_DOWN:
|
||||
if globals.current_window == 0:
|
||||
scroll_channels(1)
|
||||
elif globals.current_window == 1:
|
||||
scroll_messages(1)
|
||||
elif globals.current_window == 2:
|
||||
scroll_nodes(1)
|
||||
|
||||
elif char == curses.KEY_HOME:
|
||||
if globals.current_window == 0:
|
||||
select_channel(0)
|
||||
elif globals.current_window == 1:
|
||||
globals.selected_message = 0
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(0)
|
||||
|
||||
elif char == curses.KEY_END:
|
||||
if globals.current_window == 0:
|
||||
select_channel(len(globals.channel_list) - 1)
|
||||
elif globals.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(len(globals.node_list) - 1)
|
||||
|
||||
elif char == curses.KEY_PPAGE:
|
||||
if globals.current_window == 0:
|
||||
select_channel(globals.selected_channel - (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us
|
||||
elif globals.current_window == 1:
|
||||
globals.selected_message = max(globals.selected_message - get_msg_window_lines(), 0)
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(globals.selected_node - (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||
|
||||
elif char == curses.KEY_NPAGE:
|
||||
if globals.current_window == 0:
|
||||
select_channel(globals.selected_channel + (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us
|
||||
elif globals.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
globals.selected_message = min(globals.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines())
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
select_node(globals.selected_node + (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||
|
||||
elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT:
|
||||
delta = -1 if char == curses.KEY_LEFT else 1
|
||||
|
||||
old_window = globals.current_window
|
||||
globals.current_window = (globals.current_window + delta) % 3
|
||||
|
||||
if old_window == 0:
|
||||
channel_box.attrset(get_color("window_frame"))
|
||||
channel_box.box()
|
||||
channel_box.refresh()
|
||||
highlight_line(False, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
if old_window == 1:
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
messages_box.box()
|
||||
messages_box.refresh()
|
||||
refresh_pad(1)
|
||||
elif old_window == 2:
|
||||
draw_function_win()
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
nodes_box.box()
|
||||
nodes_box.refresh()
|
||||
highlight_line(False, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
if globals.current_window == 0:
|
||||
channel_box.attrset(get_color("window_frame_selected"))
|
||||
channel_box.box()
|
||||
channel_box.attrset(get_color("window_frame"))
|
||||
channel_box.refresh()
|
||||
highlight_line(True, 0, globals.selected_channel)
|
||||
refresh_pad(0)
|
||||
elif globals.current_window == 1:
|
||||
messages_box.attrset(get_color("window_frame_selected"))
|
||||
messages_box.box()
|
||||
messages_box.attrset(get_color("window_frame"))
|
||||
messages_box.refresh()
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
draw_node_details()
|
||||
nodes_box.attrset(get_color("window_frame_selected"))
|
||||
nodes_box.box()
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
nodes_box.refresh()
|
||||
highlight_line(True, 2, globals.selected_node)
|
||||
refresh_pad(2)
|
||||
|
||||
# Check for Esc
|
||||
elif char == chr(27):
|
||||
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
break
|
||||
|
||||
# Check for Ctrl + t
|
||||
elif char == chr(20):
|
||||
send_traceroute()
|
||||
curses.curs_set(0) # Hide cursor
|
||||
ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.")
|
||||
curses.curs_set(1) # Show cursor again
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
if globals.current_window == 2:
|
||||
node_list = globals.node_list
|
||||
if node_list[globals.selected_node] not in globals.channel_list:
|
||||
globals.channel_list.append(node_list[globals.selected_node])
|
||||
globals.all_messages[node_list[globals.selected_node]] = []
|
||||
|
||||
globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node])
|
||||
globals.selected_node = 0
|
||||
globals.current_window = 0
|
||||
|
||||
draw_node_list()
|
||||
draw_channel_list()
|
||||
draw_messages_window(True)
|
||||
|
||||
elif len(input_text) > 0:
|
||||
# Enter key pressed, send user input as message
|
||||
send_message(input_text, channel=globals.selected_channel)
|
||||
draw_messages_window(True)
|
||||
|
||||
# Clear entry window and reset input text
|
||||
input_text = ""
|
||||
entry_win.erase()
|
||||
|
||||
elif char == "\t":
|
||||
start_idx = globals.selected_node + 1 if win == 2 else globals.selected_channel + 1
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
||||
if input_text:
|
||||
input_text = input_text[:-1]
|
||||
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 == "`": # ` Launch the settings interface
|
||||
curses.curs_set(0)
|
||||
settings_menu(stdscr, globals.interface)
|
||||
curses.curs_set(1)
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char == chr(16):
|
||||
# Display packet log
|
||||
if globals.display_log is False:
|
||||
globals.display_log = True
|
||||
draw_messages_window(True)
|
||||
else:
|
||||
globals.display_log = False
|
||||
packetlog_win.erase()
|
||||
draw_messages_window(True)
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if(isinstance(char, str)):
|
||||
input_text += char
|
||||
else:
|
||||
input_text += chr(char)
|
||||
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()
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2
|
||||
import logging, traceback
|
||||
import logging
|
||||
import base64
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ def extract_fields(message_instance, current_config=None):
|
||||
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
|
||||
@@ -37,10 +35,8 @@ def extract_fields(message_instance, current_config=None):
|
||||
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": {}}
|
||||
@@ -58,7 +54,6 @@ def generate_menu_from_protobuf(interface):
|
||||
"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"
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import curses
|
||||
from ui.colors import get_color, setup_colors, COLOR_MAP
|
||||
from default_config import format_json_single_line_arrays, loaded_config
|
||||
from input_handlers import select_from_list
|
||||
from input_handlers import get_list_input
|
||||
|
||||
width = 60
|
||||
save_option_text = "Save Changes"
|
||||
@@ -14,8 +14,8 @@ def edit_color_pair(key, current_value):
|
||||
Allows the user to select a foreground and background color for a key.
|
||||
"""
|
||||
color_list = [" "] + list(COLOR_MAP.keys())
|
||||
fg_color = select_from_list(f"Select Foreground Color for {key}", current_value[0], color_list)
|
||||
bg_color = select_from_list(f"Select Background Color for {key}", current_value[1], color_list)
|
||||
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]
|
||||
|
||||
@@ -48,7 +48,10 @@ def edit_value(key, current_value):
|
||||
if key == "theme":
|
||||
# Load theme names dynamically from the JSON
|
||||
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
|
||||
return select_from_list("Select Theme", current_value, theme_options)
|
||||
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"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import globals
|
||||
from datetime import datetime
|
||||
import datetime
|
||||
from meshtastic.protobuf import config_pb2
|
||||
import default_config as config
|
||||
|
||||
def get_channels():
|
||||
"""Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
|
||||
@@ -35,13 +36,29 @@ def get_channels():
|
||||
|
||||
def get_node_list():
|
||||
if globals.interface.nodes:
|
||||
sorted_nodes = sorted(
|
||||
globals.interface.nodes.values(),
|
||||
key = lambda node: (node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0),
|
||||
reverse = True)
|
||||
return [node['num'] for node in sorted_nodes]
|
||||
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()
|
||||
myNodeNum = myinfo['num']
|
||||
@@ -55,50 +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 = ""
|
||||
nodes_snapshot = list(globals.interface.nodes.values())
|
||||
|
||||
for node in nodes_snapshot:
|
||||
if number == node['num']:
|
||||
if type == 'long':
|
||||
return node['user']['longName']
|
||||
elif type == 'short':
|
||||
return node['user']['shortName']
|
||||
else:
|
||||
pass
|
||||
# If no match is found, use the ID as a string
|
||||
return str(decimal_to_hex(number))
|
||||
|
||||
def get_time_ago(timestamp):
|
||||
now = datetime.now()
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
delta = now - dt
|
||||
|
||||
def get_time_val_units(time_delta):
|
||||
value = 0
|
||||
unit = ""
|
||||
|
||||
if delta.days > 365:
|
||||
value = delta.days // 365
|
||||
if time_delta.days > 365:
|
||||
value = time_delta.days // 365
|
||||
unit = "y"
|
||||
elif delta.days > 30:
|
||||
value = delta.days // 30
|
||||
elif time_delta.days > 30:
|
||||
value = time_delta.days // 30
|
||||
unit = "mon"
|
||||
elif delta.days > 7:
|
||||
value = delta.days // 7
|
||||
elif time_delta.days > 7:
|
||||
value = time_delta.days // 7
|
||||
unit = "w"
|
||||
elif delta.days > 0:
|
||||
value = delta.days
|
||||
elif time_delta.days > 0:
|
||||
value = time_delta.days
|
||||
unit = "d"
|
||||
elif delta.seconds > 3600:
|
||||
value = delta.seconds // 3600
|
||||
elif time_delta.seconds > 3600:
|
||||
value = time_delta.seconds // 3600
|
||||
unit = "h"
|
||||
elif delta.seconds > 60:
|
||||
value = delta.seconds // 60
|
||||
elif time_delta.seconds > 60:
|
||||
value = time_delta.seconds // 60
|
||||
unit = "min"
|
||||
else:
|
||||
value = time_delta.seconds
|
||||
unit = "s"
|
||||
return (value, unit)
|
||||
|
||||
if len(unit) > 0:
|
||||
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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user