mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
32 Commits
v1.2.0
...
rm-node-fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c100539ff9 | ||
|
|
5e1ede0bea | ||
|
|
d911176603 | ||
|
|
586724662d | ||
|
|
313c13a96a | ||
|
|
1dc0fc1f2e | ||
|
|
84dd99fc40 | ||
|
|
03328e4115 | ||
|
|
2d03f2c60c | ||
|
|
e462530930 | ||
|
|
7560b0805a | ||
|
|
b5a841d7d2 | ||
|
|
fe625efd5f | ||
|
|
25b3fc427b | ||
|
|
21e7e01703 | ||
|
|
07ce9dfbac | ||
|
|
bae197eeca | ||
|
|
d0c67f0864 | ||
|
|
6e96023906 | ||
|
|
f5b9db6d7a | ||
|
|
40c2ef62b4 | ||
|
|
d019c6371c | ||
|
|
c62965a94f | ||
|
|
a4a15e57b4 | ||
|
|
9621bb09b3 | ||
|
|
3cb265ca13 | ||
|
|
ba03f7af7e | ||
|
|
3d3d628483 | ||
|
|
466f385c31 | ||
|
|
aa2d3bded4 | ||
|
|
5dea39ae50 | ||
|
|
0464e44e0d |
@@ -1,5 +1,9 @@
|
||||
## Contact - A Console UI for Meshtastic
|
||||
Powered by Meshtastic.org
|
||||
### (Formerly Curses Client for Meshtastic)
|
||||
|
||||
#### 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="Screenshot_2024-03-29_at_4 00 29_PM" src="https://github.com/pdxlocations/meshtastic-curses-client/assets/117498748/e99533b7-5c0c-463d-8d5f-6e3cccaeced7">
|
||||
|
||||
|
||||
288
db_handler.py
288
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
|
||||
@@ -153,139 +145,130 @@ 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
|
||||
|
||||
# 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 globals.interface.nodes.values():
|
||||
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 +303,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,7 +2,7 @@ interface = None
|
||||
display_log = False
|
||||
all_messages = {}
|
||||
channel_list = []
|
||||
notifications = set()
|
||||
notifications = []
|
||||
packet_buffer = []
|
||||
node_list = []
|
||||
myNodeNum = 0
|
||||
|
||||
@@ -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, 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] + 3, enum_win.getbegyx()[1] + 4,
|
||||
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + 4 + enum_win.getmaxyx()[1] - 4)
|
||||
|
||||
8
main.py
8
main.py
@@ -21,10 +21,14 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,9 +25,8 @@ def on_receive(packet, interface):
|
||||
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
|
||||
changed = refresh_node_list()
|
||||
if(changed):
|
||||
draw_node_list()
|
||||
|
||||
if packet['decoded']['portnum'] == 'NODEINFO_APP':
|
||||
@@ -50,7 +50,9 @@ def on_receive(packet, interface):
|
||||
pass
|
||||
else:
|
||||
globals.channel_list.append(packet['from'])
|
||||
globals.all_messages[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'])
|
||||
@@ -99,4 +101,4 @@ def on_receive(packet, interface):
|
||||
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}")
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
|
||||
@@ -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
|
||||
|
||||
63
settings.py
63
settings.py
@@ -2,9 +2,9 @@ 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
|
||||
@@ -171,7 +171,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 +187,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 +204,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 +253,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 +272,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 +281,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 +290,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"
|
||||
|
||||
135
ui/curses_ui.py
135
ui/curses_ui.py
@@ -1,55 +1,77 @@
|
||||
import curses
|
||||
import textwrap
|
||||
from utilities.utils import get_channels, get_time_ago
|
||||
import time
|
||||
from utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
|
||||
from settings import settings_menu
|
||||
from input_handlers import get_list_input
|
||||
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())
|
||||
|
||||
node = None
|
||||
for node in nodes_snapshot:
|
||||
if globals.node_list[globals.selected_node] == node['num']:
|
||||
break
|
||||
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']}"
|
||||
|
||||
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 "",
|
||||
]
|
||||
if 'user' in node and 'hwModel' 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:
|
||||
if len(nodestr) + len(s) < width - 2:
|
||||
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"]
|
||||
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]):
|
||||
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_box.getmaxyx()[0] - 2 - packetlog_height
|
||||
@@ -99,7 +121,7 @@ def highlight_line(highlight, window, line):
|
||||
pad.chgat(line, 1, select_len, nd_color | curses.A_REVERSE if highlight else nd_color)
|
||||
|
||||
if(window == 0):
|
||||
channel = list(globals.all_messages.keys())[line]
|
||||
channel = globals.channel_list[line]
|
||||
win_width = channel_box.getmaxyx()[1]
|
||||
|
||||
if(isinstance(channel, int)):
|
||||
@@ -112,10 +134,12 @@ def highlight_line(highlight, window, line):
|
||||
pad.chgat(line, 1, select_len, ch_color | curses.A_REVERSE if highlight else ch_color)
|
||||
|
||||
def add_notification(channel_number):
|
||||
globals.notifications.add(channel_number)
|
||||
if channel_number not in globals.notifications:
|
||||
globals.notifications.append(channel_number)
|
||||
|
||||
def remove_notification(channel_number):
|
||||
globals.notifications.discard(channel_number)
|
||||
if channel_number in globals.notifications:
|
||||
globals.notifications.remove(channel_number)
|
||||
|
||||
def draw_text_field(win, text, color):
|
||||
win.border()
|
||||
@@ -166,24 +190,28 @@ def draw_channel_list():
|
||||
|
||||
channel_pad.resize(len(globals.all_messages), channel_box.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:
|
||||
if idx == globals.selected_channel:
|
||||
if globals.current_window == 0:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list", reverse=True))
|
||||
channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_list", reverse=True))
|
||||
remove_notification(globals.selected_channel)
|
||||
else:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_selected"))
|
||||
channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_selected"))
|
||||
else:
|
||||
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list"))
|
||||
channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_list"))
|
||||
idx += 1
|
||||
|
||||
channel_box.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame"))
|
||||
channel_box.box()
|
||||
@@ -292,7 +320,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
|
||||
@@ -463,10 +491,6 @@ def main_ui(stdscr):
|
||||
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)
|
||||
@@ -552,7 +576,7 @@ def main_ui(stdscr):
|
||||
messages_box.refresh()
|
||||
refresh_pad(1)
|
||||
elif globals.current_window == 2:
|
||||
draw_node_details()
|
||||
draw_function_win()
|
||||
nodes_box.attrset(get_color("window_frame_selected"))
|
||||
nodes_box.box()
|
||||
nodes_box.attrset(get_color("window_frame"))
|
||||
@@ -577,9 +601,15 @@ def main_ui(stdscr):
|
||||
node_list = globals.node_list
|
||||
if node_list[globals.selected_node] not in globals.channel_list:
|
||||
globals.channel_list.append(node_list[globals.selected_node])
|
||||
if(node_list[globals.selected_node] not in globals.all_messages):
|
||||
globals.all_messages[node_list[globals.selected_node]] = []
|
||||
|
||||
|
||||
globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node])
|
||||
|
||||
if(is_chat_archived(globals.channel_list[globals.selected_channel])):
|
||||
update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=False)
|
||||
|
||||
globals.selected_node = 0
|
||||
globals.current_window = 0
|
||||
|
||||
@@ -609,6 +639,7 @@ def main_ui(stdscr):
|
||||
curses.curs_set(0)
|
||||
settings_menu(stdscr, globals.interface)
|
||||
curses.curs_set(1)
|
||||
refresh_node_list()
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
elif char == chr(16):
|
||||
@@ -620,6 +651,42 @@ def main_ui(stdscr):
|
||||
globals.display_log = False
|
||||
packetlog_win.erase()
|
||||
draw_messages_window(True)
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
# ^D
|
||||
elif char == chr(4):
|
||||
if(globals.current_window == 0):
|
||||
if(isinstance(globals.channel_list[globals.selected_channel], int)):
|
||||
update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=True)
|
||||
|
||||
# Shift notifications up to account for deleted item
|
||||
for i in range(len(globals.notifications)):
|
||||
if globals.notifications[i] > globals.selected_channel:
|
||||
globals.notifications[i] -= 1
|
||||
|
||||
del globals.channel_list[globals.selected_channel]
|
||||
globals.selected_channel = min(globals.selected_channel, len(globals.channel_list) - 1)
|
||||
select_channel(globals.selected_channel)
|
||||
draw_channel_list()
|
||||
draw_messages_window()
|
||||
|
||||
if(globals.current_window == 2):
|
||||
curses.curs_set(0)
|
||||
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from nodedb?", "no", ["yes", "no"])
|
||||
if confirmation == "yes":
|
||||
globals.interface.localNode.removeNode(globals.node_list[globals.selected_node])
|
||||
globals.node_list.pop(globals.selected_node)
|
||||
|
||||
draw_messages_window()
|
||||
draw_node_list()
|
||||
else:
|
||||
draw_messages_window()
|
||||
curses.curs_set(1)
|
||||
continue
|
||||
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if(isinstance(char, str)):
|
||||
|
||||
@@ -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