Compare commits

...

150 Commits

Author SHA1 Message Date
pdxlocations
852a912072 bump version 2025-01-28 18:09:18 -08:00
pdxlocations
af5fe53658 Add App Settings Menu (#89)
* init

* working changes

* working changes

* working changes

* working changes

* not working changes

* almost working changes

* working changes

* working changes

* broke save and nested menus

* working better

* changes

* working changes

* scrolling text input

* allow wide char input

* set pad bg colors

* add empty color for bg

* reload colors on save

* tab to save changes

* cleanup on isle edit_value

* dynamically create theme options
2025-01-28 16:52:20 -08:00
pdxlocations
f21269ba62 set a few pad bg colors 2025-01-28 13:18:46 -08:00
pdxlocations
3f94b9e276 Merge pull request #88 from rfschmid/fix-crash-in-node-details
Fix crash in get node details
2025-01-28 10:46:04 -08:00
Russell Schmidt
c865d6a942 Fix crash in get node details
The last heard timestamp apparently sometimes gets populated in the dict
as None, instead of just not being present, which caused a crash. Check
for None before tryint to use it.
2025-01-28 12:42:59 -06:00
pdxlocations
0bbabba77b Merge pull request #85 from rfschmid:add-node-details
Add node details
2025-01-27 16:28:54 -08:00
pdxlocations
bf43799a7d Merge pull request #86 from rfschmid/replace-clear-with-erase 2025-01-27 16:26:37 -08:00
pdxlocations
dd0ce4f098 Merge pull request #87 from rfschmid/fix-packet-log-disappearing 2025-01-27 16:24:31 -08:00
Russell Schmidt
5bd9b45753 Fix packet log disappearing 2025-01-27 17:16:56 -06:00
Russell Schmidt
4aaef5381e Replace window.clear() calls with window.erase()
https://lists.gnu.org/archive/html/bug-ncurses/2014-01/msg00007.html
2025-01-27 12:58:01 -06:00
Russell Schmidt
51dcfb5aa2 Add node details 2025-01-27 12:46:33 -06:00
pdxlocations
77b995f00f Merge pull request #81 from rfschmid/make-tab-jump-to-save-in-settings 2025-01-26 17:54:13 -08:00
pdxlocations
62cc2089db Merge pull request #82 from rfschmid:refresh-display-when-closing-dialogs
Refresh display when closing dialogs
2025-01-26 17:52:55 -08:00
pdxlocations
2eb8a17094 Merge pull request #80 from rfschmid/reduce-refresh-on-bool-input 2025-01-26 17:51:09 -08:00
pdxlocations
abe400648f Merge pull request #79 from rfschmid:fix-cancelling-input-crash
Fix crash on cancelling settings input
2025-01-26 17:49:29 -08:00
Russell Schmidt
22b2a9a50e Make refresh more efficient 2025-01-26 18:33:46 -06:00
Russell Schmidt
9a306f1553 Refresh display when closing dialogs 2025-01-26 18:12:10 -06:00
Russell Schmidt
92db3f4a30 Make tab jump to save in settings 2025-01-26 16:41:13 -06:00
Russell Schmidt
a32526e650 Reduce screen refresh on bool input 2025-01-26 16:29:48 -06:00
Russell Schmidt
1ebf1c4988 Fix crash on cancelling settings input
When backing out of entering user short name or long name, the app would
crash. Once it didn't crash, backing out would set these fields to None,
rather than cancelling the change.
2025-01-26 16:07:04 -06:00
pdxlocations
7901f00c49 Merge pull request #78 from rfschmid:fix-crash-on-long-enum-settings
Allow enum settings entry to scroll
2025-01-26 13:25:57 -08:00
Russell Schmidt
4ce279ab0d Allow enum settings entry to scroll 2025-01-26 15:01:38 -06:00
pdxlocations
e8e91f893e Merge pull request #76 from rfschmid/allow-settings-wraparound-scroll 2025-01-26 12:37:51 -08:00
pdxlocations
16c81f059d Merge pull request #75 from rfschmid:fix-settings-crash-when-height-smol
Enable scrolling settings options
2025-01-26 12:36:14 -08:00
pdxlocations
5588c6c6d9 Merge pull request #77 from rfschmid:remember-settings-menu-selected-idx
Remember settings menu stack selected index
2025-01-26 12:35:12 -08:00
Russell Schmidt
73111a46bb Fix exception adding last item in settings 2025-01-26 13:19:56 -06:00
Russell Schmidt
2d762515b4 Maybe fix crash with settings scroll 2025-01-26 11:22:59 -06:00
Russell Schmidt
6ce9707232 Remember settings menu stack selected index 2025-01-26 11:19:29 -06:00
Russell Schmidt
c33b903825 Allow settings wraparound scrolling 2025-01-26 11:12:38 -06:00
Russell Schmidt
c5327d8644 Enable scrolling settings options
Fixes crash in settings when the window height is too small to
accommodate the full list of options.
2025-01-26 10:33:56 -06:00
pdxlocations
ed1e9a3055 reorder config menu 2025-01-25 20:45:23 -08:00
pdxlocations
f0554ec1f6 bump version 2025-01-25 20:42:16 -08:00
Russell Schmidt
bb623d149c Fix crash when cancelling shutdown/reboot/etc (#73) 2025-01-25 20:39:04 -08:00
pdxlocations
4a92ad49ce missed some coloring 2025-01-25 18:57:04 -08:00
pdxlocations
9d0470d55b just call it green 2025-01-25 18:27:48 -08:00
pdxlocations
e96ea7ffef Green Theme for Josh (#72)
* add themes

* change tty defaults
2025-01-25 18:23:30 -08:00
pdxlocations
44b1a3071b fix packet log border color 2025-01-25 18:02:37 -08:00
pdxlocations
702d20a011 fix sesitive settings highlight 2025-01-25 16:25:47 -08:00
pdxlocations
fba4642ff8 fix settings crash 2025-01-25 16:02:40 -08:00
pdxlocations
916b0cfe53 fix splash border 2025-01-25 15:36:24 -08:00
pdxlocations
e086814b83 fix color fixes 2025-01-25 15:30:06 -08:00
pdxlocations
92f08d020e cleanup comments 2025-01-25 12:29:05 -08:00
pdxlocations
86463f6f84 maybe fix setting enum (#69) 2025-01-25 11:30:53 -08:00
pdxlocations
e58340fa65 extra bool check 2025-01-25 09:10:35 -08:00
pdxlocations
c243daf253 Fix channels saving to wrong index 2025-01-25 08:43:17 -08:00
pdxlocations
70f1f5d4bf late version bump 2025-01-25 07:48:41 -08:00
pdxlocations
67f1bde217 rm incorrect comment 2025-01-24 22:34:50 -08:00
pdxlocations
ca17bbee31 color fixes 2025-01-24 22:12:25 -08:00
pdxlocations
659dad493c check for new default colors 2025-01-24 21:38:51 -08:00
pdxlocations
4359d37979 add settings box color 2025-01-24 21:25:22 -08:00
pdxlocations
96612b3e1b rm print statements 2025-01-24 21:14:17 -08:00
pdxlocations
84246aefd9 Move user config into JSON file (#68)
* working changes

* Reduce redraws in settings (#66)

* Reduce redraws in settings

* Fix drawing sensitive settings in red

* Update other settings to use new color API

* Update reduce redraw changes to use new color API

* Fix highlight/unhighlight red settings

* add settings colors

* format json file

---------

Co-authored-by: Russell Schmidt <russ@sumonkey.com>
2025-01-24 21:00:00 -08:00
Russell Schmidt
7cd98a39f8 Reduce redraws in settings (#66)
* Reduce redraws in settings

* Fix drawing sensitive settings in red

* Update other settings to use new color API

* Update reduce redraw changes to use new color API

* Fix highlight/unhighlight red settings
2025-01-24 18:12:57 -08:00
pdxlocations
978e2942cb fix setting is_licensed bool 2025-01-24 09:55:42 -08:00
pdxlocations
92790ddca6 use local time for timestamp 2025-01-24 08:35:39 -08:00
pdxlocations
d5a6a0462f Color Customization (#64)
* init

* customize colors

* fix splash frame

* cleanup
2025-01-23 23:08:01 -08:00
pdxlocations
3816a3f166 Merge pull request #62 from rfschmid/stop-sending-blank
Don't allow sending empty string
2025-01-23 18:21:09 -08:00
Russell Schmidt
c61dc19319 Don't allow sending empty string 2025-01-23 19:15:35 -06:00
pdxlocations
91d331af4f Merge pull request #60 from rfschmid/add-home-end-pgup-pgdn-to-channels-nodes 2025-01-23 16:15:43 -08:00
pdxlocations
be92ac5de3 Merge pull request #61 from rfschmid/box-active-window-in-green 2025-01-23 16:12:31 -08:00
Russell Schmidt
10179c4179 Add home/end/pgup/pgdn to channels and nodes 2025-01-23 17:38:43 -06:00
pdxlocations
6e45caaac2 clear menu_win on escape 2025-01-23 15:34:08 -08:00
Russell Schmidt
152555156e Outline active window in green 2025-01-23 17:28:12 -06:00
pdxlocations
056b8b5f5f Merge pull request #59 from pdxlocations/timestamps 2025-01-23 15:20:27 -08:00
pdxlocations
07f0721fd5 add hourly timestamp to rx and tx messages 2025-01-23 15:19:45 -08:00
pdxlocations
cf9276d399 add argparse to settings.py 2025-01-23 12:44:16 -08:00
pdxlocations
482d158b15 sort globals 2025-01-23 12:29:14 -08:00
pdxlocations
11bd9c75ed Add Hourly Timestamps (#58)
* init

* formatting

* white stamps and no seconds
2025-01-23 12:21:38 -08:00
pdxlocations
1b2701e1a1 white stamps and no seconds 2025-01-23 12:20:57 -08:00
pdxlocations
7fcc9abd76 formatting 2025-01-23 12:16:59 -08:00
pdxlocations
9bfddb954d init 2025-01-23 12:08:14 -08:00
pdxlocations
deb231fc63 Merge pull request #56 from rfschmid/stop-refreshing-when-changing-windows 2025-01-23 11:12:50 -08:00
pdxlocations
bbe9e66fa5 Merge pull request #54 from rfschmid:convert-channels-window-to-pad
Convert channels window to pad
2025-01-23 11:06:00 -08:00
Russell Schmidt
aa7d98b1b0 Stop refreshing when changing windows 2025-01-23 12:53:55 -06:00
Russell Schmidt
8895784503 Fix not clearing notifications 2025-01-23 12:29:32 -06:00
Russell Schmidt
695b4949c0 Fix drawing messages when switching channels 2025-01-23 12:09:21 -06:00
Russell Schmidt
404bac9133 Convert channels window to pad 2025-01-23 08:09:38 -06:00
pdxlocations
4698b81a3f Fix broken settings and simplify bool handlers (#53)
* underp modified settings and simplify bool  handling

* easier to read modified settings

* rm spaces
2025-01-22 22:21:52 -08:00
pdxlocations
e7e9f24fe2 Merge pull request #52 from pdxlocations/get-name-from-number-error
Don't iterate over the db while writing to it.
2025-01-22 20:56:12 -08:00
pdxlocations
01f67ea8b5 db snapshot 2025-01-22 20:54:51 -08:00
pdxlocations
c19684c119 Merge pull request #51 from rfschmid/convert-nodes-window-to-pad
Convert nodes window to pad
2025-01-22 20:43:44 -08:00
pdxlocations
0b0d8c482b Merge pull request #50 from rfschmid/add-home-end-page-support 2025-01-22 20:40:47 -08:00
Russell Schmidt
f9774b2248 Convert nodes window to pad 2025-01-22 21:39:19 -06:00
Russell Schmidt
d6db1e1832 Add home/end/pageup/pagedown message support 2025-01-22 20:48:14 -06:00
pdxlocations
4c85aaecdf sent messages cyan 2025-01-22 18:21:51 -08:00
pdxlocations
d8fc02b28a Merge pull request #36 from rfschmid/convert-messages-window-to-pad
Convert messages window to pad
2025-01-22 17:47:04 -08:00
Russell Schmidt
039673bb18 Disable message window looping 2025-01-22 19:41:30 -06:00
pdxlocations
d81e694ee6 Merge pull request #49 from pdxlocations/settings-cleanup
Add is_licensed to user settings
2025-01-22 17:32:38 -08:00
pdxlocations
dfea291d21 typoe 2025-01-22 17:31:33 -08:00
pdxlocations
41c60a49e9 Merge pull request #48 from rfschmid/fix-crash-on-long-input 2025-01-22 17:00:58 -08:00
pdxlocations
fb3138883f Merge pull request #47 from rfschmid/make-selected-channel-green 2025-01-22 16:54:56 -08:00
pdxlocations
767f0e2288 add is_licensed 2025-01-22 16:47:49 -08:00
Russell Schmidt
43680f8afb Fix crash on long input 2025-01-22 17:25:12 -06:00
Russell Schmidt
e15f625716 Fix default channel messages not showing on start 2025-01-22 17:14:49 -06:00
Russell Schmidt
c090b3dd58 Make messages pad actually work 2025-01-22 17:11:32 -06:00
pdxlocations
f472a3040c remove some unused code 2025-01-22 12:42:35 -08:00
pdxlocations
4a0c49b7d6 typo and cleanup 2025-01-22 12:18:58 -08:00
pdxlocations
3034a1464a ok to show duplicate messages from db 2025-01-22 11:59:06 -08:00
Russell Schmidt
80fe10c050 Make selected channel green
So you can tell which channel you're looking at when the channel list
isn't highlighted
2025-01-22 12:52:03 -06:00
pdxlocations
0962c5b284 break out of settings 2025-01-22 10:43:42 -08:00
pdxlocations
1b3abdebf2 some settings cleanup 2025-01-22 08:41:22 -08:00
pdxlocations
a710374fe9 refactor notification 2025-01-22 08:20:51 -08:00
Russell Schmidt
cb088c51d4 Count total lines of messages 2025-01-21 19:10:43 -06:00
Russell Schmidt
ccb46b8553 Account for packet log window 2025-01-21 19:10:33 -06:00
Russell Schmidt
35748d071e Various tweaks, added back messages border 2025-01-21 19:09:46 -06:00
Russell Schmidt
7e85085b98 WIP - Convert messages window to pad
Change to use a curses "pad" so we can scroll better
2025-01-21 19:09:44 -06:00
pdxlocations
7493d21c1a Merge pull request #45 from rfschmid/make-packet-log-ctrl-p 2025-01-21 15:54:50 -08:00
Russell Schmidt
d0af0e6af1 Make forward slash possible to type
Packet log is now Ctrl + P
2025-01-21 17:39:49 -06:00
pdxlocations
796c40b560 Merge pull request #43 from rfschmid/fix-wide-character-input
Fix wide character input
2025-01-21 15:14:59 -08:00
Russell Schmidt
1c0704b940 Handle backspace character
Co-authored-by: pdxlocations <117498748+pdxlocations@users.noreply.github.com>
2025-01-21 17:13:46 -06:00
Russell Schmidt
6384777bb6 Delete leftover comment 2025-01-21 14:58:34 -06:00
Russell Schmidt
2fbaee5fc5 Fix input of wide characters 2025-01-21 12:44:41 -06:00
pdxlocations
06e71331b6 select last message when changing channels 2025-01-21 09:54:33 -08:00
pdxlocations
fea705a09f Merge pull request #41 from pdxlocations/fix-repeated-input
ignore-incoming work - still not saving
2025-01-21 09:27:07 -08:00
pdxlocations
a47a4a9b32 ignore-incoming work - still not saving 2025-01-21 08:28:41 -08:00
pdxlocations
bc72c3b0b6 break out color definitions 2025-01-20 23:01:04 -08:00
pdxlocations
559618a229 Merge pull request #40 from pdxlocations/fix-wrapped-lines-out-of-bounds
fix text wrap line count
2025-01-20 21:04:41 -08:00
pdxlocations
2abdd763c1 fix text wrap line count 2025-01-20 20:51:03 -08:00
pdxlocations
89d8b7690f New Settings Menu (#39)
* init

* backup old code

* working changes

* bckup old settings

* working changes

* working changes

* rename backup

* current state of things

* starting to work

* no test for you

* working changes

* working changes

* semi-working changes

* changes

* integrating with contact main.py

* working changes

* working changes

* working changes

* rm settings.log

* bool messages and sys function confirmation

* start IP's and sub-categories

* display enum names

* fix deep nested configs
2025-01-20 16:02:20 -08:00
pdxlocations
d267f41805 Merge pull request #38 from rfschmid/fix-potential-crash-in-sort-nodes 2025-01-20 09:11:13 -08:00
Russell Schmidt
72939e5988 Fix potential crash in sort nodes 2025-01-20 09:41:26 -06:00
pdxlocations
bd5d8aa6e4 Merge pull request #34 from rfschmid/remove-unused-sanitize-string 2025-01-17 11:42:25 -08:00
pdxlocations
5899f35833 Merge pull request #33 from rfschmid/okay-dialog-improvements 2025-01-17 11:41:21 -08:00
pdxlocations
550556df2b Merge pull request #32 from rfschmid/fix-sql-injection 2025-01-17 11:38:43 -08:00
pdxlocations
fac22aee91 Merge pull request #31 from rfschmid/limit-more-drawing 2025-01-17 11:37:46 -08:00
Russell Schmidt
87e68689f4 Remove unused sanitize string function 2025-01-17 12:59:16 -06:00
Russell Schmidt
fe1eaacee9 Make dialog easier to get out of 2025-01-17 12:55:43 -06:00
Russell Schmidt
94e7e8f628 Make dialog "Ok" look highlighted 2025-01-17 12:49:39 -06:00
Russell Schmidt
54ec4009a1 Fix sql injection in update_ack_nak
Messages with single quotes would send data directly to sqlite
2025-01-17 12:26:47 -06:00
Russell Schmidt
9901e5b8a0 Limit more drawing in tx handler 2025-01-17 07:59:13 -06:00
Russell Schmidt
80a5edb6de Limit redrawing on message receipt 2025-01-17 07:45:01 -06:00
Russell Schmidt
93664397e8 Refresh node list only on change 2025-01-17 07:40:08 -06:00
pdxlocations
5bd33ed786 Merge pull request #30 from rfschmid/limit-refreshing
Limit refreshing on changing windows
2025-01-16 16:37:07 -08:00
Russell Schmidt
fae3330bb0 Limit refreshing on changing windows 2025-01-16 18:21:17 -06:00
pdxlocations
5bd91bde25 Merge pull request #26 from rfschmid/add-traceroute-support
Add traceroute support
2025-01-16 11:37:56 -08:00
Russell Schmidt
2a7317e612 Add warning about 30 second traceroute interval 2025-01-16 13:06:36 -06:00
Russell Schmidt
68f13585b6 Make traceroute less verbose 2025-01-16 12:51:31 -06:00
Russell Schmidt
ad81d34551 typo
Co-authored-by: pdxlocations <117498748+pdxlocations@users.noreply.github.com>
2025-01-16 12:44:19 -06:00
Russell Schmidt
4c4c0d553e Add dialog confirmation when traceroute sent 2025-01-16 12:07:29 -06:00
Russell Schmidt
02c104dcd5 Remove unused imports 2025-01-16 07:59:22 -06:00
Russell Schmidt
96493e5973 Add traceroute support 2025-01-16 07:49:01 -06:00
Russell Schmidt
8a13b60d23 Small cleanup in rx handler 2025-01-16 07:38:28 -06:00
pdxlocations
7ae4bb7c9d just get my node number once 2025-01-15 22:28:44 -08:00
pdxlocations
36ba9065a2 typo 2025-01-15 21:35:07 -08:00
pdxlocations
2ad2aa1faa fix gitignore 2025-01-15 21:33:32 -08:00
pdxlocations
342afce9fb start logging 2025-01-15 21:32:30 -08:00
pdxlocations
205a0c547d Merge pull request #25 from rfschmid/persist-ack-to-db
Persist message acks to db
2025-01-15 19:50:12 -08:00
Russell Schmidt
daa94f57a6 Persist message acks to db 2025-01-15 17:32:39 -06:00
pdxlocations
5e17e8e7d3 Merge pull request #24 from rfschmid/sort-nodes-by-last-heard
Sort nodes list by last heard
2025-01-15 14:19:48 -08:00
Russell Schmidt
455c3b10dd Sort nodes list by last heard 2025-01-15 12:37:40 -06:00
17 changed files with 2149 additions and 1027 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,9 @@
venv/
.venv/
__pycache__/
client.db
.DS_Store
client.log
settings.log
config.json
default_config.log

View File

@@ -1,63 +1,105 @@
import sqlite3
import globals
import time
from utilities.utils import get_nodeNum, get_name_from_number
from datetime import datetime
import logging
import globals
import default_config as config
from utilities.utils import get_name_from_number
def get_table_name(channel):
# Construct the table name
table_name = f"{str(globals.myNodeNum)}_{channel}_messages"
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
return quoted_table_name
def save_message_to_db(channel, user_id, message_text):
"""Save messages to the database, ensuring the table exists."""
try:
with sqlite3.connect(globals.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
# Construct the table name
table_name = f"{str(get_nodeNum())}_{channel}_messages"
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
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
timestamp INTEGER,
ack_type TEXT
)
'''
db_cursor.execute(create_table_query)
timestamp = int(time.time())
# Insert the message
insert_query = f'''
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp)
VALUES (?, ?, ?)
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
'''
db_cursor.execute(insert_query, (user_id, message_text, int(time.time())))
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_connection.commit()
return timestamp
except sqlite3.Error as e:
logging.error(f"SQLite error in save_message_to_db: {e}")
except Exception as e:
logging.error(f"Unexpected error in save_message_to_db: {e}")
def update_ack_nak(channel, timestamp, message, ack):
try:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
update_query = f"""
UPDATE {get_table_name(channel)}
SET ack_type = ?
WHERE user_id = ? AND
timestamp = ? AND
message_text = ?
"""
db_cursor.execute(update_query, (ack, str(globals.myNodeNum), timestamp, message))
db_connection.commit()
except sqlite3.Error as e:
print(f"SQLite error in save_message_to_db: {e}")
logging.error(f"SQLite error in update_ack_nak: {e}")
except Exception as e:
print(f"Unexpected error in save_message_to_db: {e}")
logging.error(f"Unexpected error in update_ack_nak: {e}")
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(globals.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
# Retrieve all table names that match the pattern
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
db_cursor.execute(query, (f"{str(get_nodeNum())}_%_messages",))
db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()]
# Iterate through each table and fetch its messages
for table_name in tables:
query = f'SELECT user_id, message_text FROM "{table_name}"'
quoted_table_name = f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({quoted_table_name})')]
if "ack_type" not in table_columns:
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
db_cursor.execute(update_table_query)
query = f'SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}'
try:
# Fetch all messages from the table
db_cursor.execute(query)
db_messages = [(row[0], row[1]) for row in db_cursor.fetchall()] # Save as tuples
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
# Extract the channel name from the table name
channel = table_name.split("_")[1]
@@ -73,31 +115,48 @@ def load_messages_from_db():
if channel not in globals.all_messages:
globals.all_messages[channel] = []
# Add messages to globals.all_messages in tuple format
for user_id, message in db_messages:
if user_id == str(get_nodeNum()):
formatted_message = (f"{globals.sent_message_prefix}: ", message)
else:
formatted_message = (f"{globals.message_prefix} {get_name_from_number(int(user_id), 'short')}: ", message)
if formatted_message not in globals.all_messages[channel]:
globals.all_messages[channel].append(formatted_message)
# Add messages to globals.all_messages grouped by hourly timestamp
hourly_messages = {}
for user_id, message, timestamp, ack_type in db_messages:
hour = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:00')
if hour not in hourly_messages:
hourly_messages[hour] = []
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
if user_id == str(globals.myNodeNum):
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
else:
formatted_message = (f"{config.message_prefix} {get_name_from_number(int(user_id), 'short')}: ", message)
hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into globals.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()):
globals.all_messages[channel].append((f"-- {hour} --", ""))
globals.all_messages[channel].extend(messages)
except sqlite3.Error as e:
print(f"SQLite error while loading messages from table '{table_name}': {e}")
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
except sqlite3.Error as e:
print(f"SQLite error in load_messages_from_db: {e}")
logging.error(f"SQLite error in load_messages_from_db: {e}")
def init_nodedb():
"""Initialize the node database and update it with nodes from the interface."""
try:
with sqlite3.connect(globals.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
# Table name construction
table_name = f"{str(get_nodeNum())}_nodedb"
table_name = f"{str(globals.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"' # Quote the table name because it might begin with numerics
# Create the table if it doesn't exist
@@ -139,16 +198,16 @@ def init_nodedb():
db_connection.commit()
except sqlite3.Error as e:
print(f"SQLite error in init_nodedb: {e}")
logging.error(f"SQLite error in init_nodedb: {e}")
except Exception as e:
print(f"Unexpected error in init_nodedb: {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."""
try:
with sqlite3.connect(globals.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path) as db_connection:
table_name = f"{str(get_nodeNum())}_nodedb"
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()
@@ -223,7 +282,6 @@ def maybe_store_nodeinfo_in_db(packet):
except sqlite3.Error as e:
print(f"SQLite error in maybe_store_nodeinfo_in_db: {e}")
logging.error(f"SQLite error in maybe_store_nodeinfo_in_db: {e}")
finally:
db_connection.close()

190
default_config.py Normal file
View File

@@ -0,0 +1,190 @@
import os
import json
import logging
def format_json_single_line_arrays(data, indent=4):
"""
Formats JSON with arrays on a single line while keeping other elements properly indented.
"""
def format_value(value, current_indent):
if isinstance(value, dict):
items = []
for key, val in value.items():
items.append(
f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}'
)
return "{\n" + ",\n".join(items) + f"\n{' ' * (current_indent - indent)}}}"
elif isinstance(value, list):
return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]"
else:
return json.dumps(value, ensure_ascii=False)
return format_value(data, indent)
# Recursive function to check and update nested dictionaries
def update_dict(default, actual):
updated = False
for key, value in default.items():
if key not in actual:
actual[key] = value
updated = True
elif isinstance(value, dict):
# Recursively check nested dictionaries
updated = update_dict(value, actual[key]) or updated
return updated
def initialize_config():
app_directory = os.path.dirname(os.path.abspath(__file__))
json_file_path = os.path.join(app_directory, "config.json")
COLOR_CONFIG_DARK = {
"default": ["white", "black"],
"background": [" ", "black"],
"splash_logo": ["green", "black"],
"splash_text": ["white", "black"],
"input": ["white", "black"],
"node_list": ["white", "black"],
"channel_list": ["white", "black"],
"channel_selected": ["green", "black"],
"rx_messages": ["cyan", "black"],
"tx_messages": ["green", "black"],
"timestamps": ["white", "black"],
"commands": ["white", "black"],
"window_frame": ["white", "black"],
"window_frame_selected": ["green", "black"],
"log_header": ["blue", "black"],
"log": ["green", "black"],
"settings_default": ["white", "black"],
"settings_sensitive": ["red", "black"],
"settings_save": ["green", "black"],
"settings_breadcrumbs": ["white", "black"]
}
COLOR_CONFIG_LIGHT = {
"default": ["black", "white"],
"background": [" ", "white"],
"splash_logo": ["green", "white"],
"splash_text": ["black", "white"],
"input": ["black", "white"],
"node_list": ["black", "white"],
"channel_list": ["black", "white"],
"channel_selected": ["green", "white"],
"rx_messages": ["cyan", "white"],
"tx_messages": ["green", "white"],
"timestamps": ["black", "white"],
"commands": ["black", "white"],
"window_frame": ["black", "white"],
"window_frame_selected": ["green", "white"],
"log_header": ["black", "white"],
"log": ["blue", "white"],
"settings_default": ["black", "white"],
"settings_sensitive": ["red", "white"],
"settings_save": ["green", "white"],
"settings_breadcrumbs": ["black", "white"]
}
COLOR_CONFIG_GREEN = {
"default": ["green", "black"],
"background": [" ", "black"],
"splash_logo": ["green", "black"],
"splash_text": ["green", "black"],
"input": ["green", "black"],
"node_list": ["green", "black"],
"channel_list": ["green", "black"],
"channel_selected": ["cyan", "black"],
"rx_messages": ["green", "black"],
"tx_messages": ["green", "black"],
"timestamps": ["green", "black"],
"commands": ["green", "black"],
"window_frame": ["green", "black"],
"window_frame_selected": ["cyan", "black"],
"log_header": ["green", "black"],
"log": ["green", "black"],
"settings_default": ["green", "black"],
"settings_sensitive": ["green", "black"],
"settings_save": ["green", "black"],
"settings_breadcrumbs": ["green", "black"]
}
default_config_variables = {
"db_file_path": os.path.join(app_directory, "client.db"),
"log_file_path": os.path.join(app_directory, "client.log"),
"message_prefix": ">>",
"sent_message_prefix": ">> Sent",
"notification_symbol": "*",
"ack_implicit_str": "[◌]",
"ack_str": "[✓]",
"nak_str": "[x]",
"ack_unknown_str": "[…]",
"theme": "dark",
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN
}
if not os.path.exists(json_file_path):
with open(json_file_path, "w", encoding="utf-8") as json_file:
formatted_json = format_json_single_line_arrays(default_config_variables)
json_file.write(formatted_json)
# Ensure all default variables exist in the JSON file
with open(json_file_path, "r", encoding="utf-8") as json_file:
loaded_config = json.load(json_file)
# Check and add missing variables
updated = update_dict(default_config_variables, loaded_config)
# Update the JSON file if any variables were missing
if updated:
formatted_json = format_json_single_line_arrays(loaded_config)
with open(json_file_path, "w", encoding="utf-8") as json_file:
json_file.write(formatted_json)
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
return loaded_config
def assign_config_variables(loaded_config):
# Assign values to local variables
global db_file_path, log_file_path, message_prefix, sent_message_prefix
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
global theme, COLOR_CONFIG
db_file_path = loaded_config["db_file_path"]
log_file_path = loaded_config["log_file_path"]
message_prefix = loaded_config["message_prefix"]
sent_message_prefix = loaded_config["sent_message_prefix"]
notification_symbol = loaded_config["notification_symbol"]
ack_implicit_str = loaded_config["ack_implicit_str"]
ack_str = loaded_config["ack_str"]
nak_str = loaded_config["nak_str"]
ack_unknown_str = loaded_config["ack_unknown_str"]
theme = loaded_config["theme"]
if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
elif theme == "light":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
elif theme == "green":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
# Call the function when the script is imported
loaded_config = initialize_config()
assign_config_variables(loaded_config)
if __name__ == "__main__":
logging.basicConfig(
filename="default_config.log",
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s"
)
print("\nLoaded Configuration:")
print(f"Database File Path: {db_file_path}")
print(f"Log File Path: {log_file_path}")
print(f"Message Prefix: {message_prefix}")
print(f"Sent Message Prefix: {sent_message_prefix}")
print(f"Notification Symbol: {notification_symbol}")
print(f"ACK Implicit String: {ack_implicit_str}")
print(f"ACK String: {ack_str}")
print(f"NAK String: {nak_str}")
print(f"ACK Unknown String: {ack_unknown_str}")
print(f"Color Config: {COLOR_CONFIG}")

View File

@@ -1,19 +1,12 @@
import os
app_directory = os.path.dirname(os.path.abspath(__file__))
db_file_path = os.path.join(app_directory, "client.db")
interface = None
display_log = False
all_messages = {}
channel_list = []
notifications = set()
packet_buffer = []
node_list = []
myNodeNum = 0
selected_channel = 0
selected_message = 0
selected_node = 0
current_window = 0
interface = None
display_log = False
message_prefix = ">>"
sent_message_prefix = message_prefix + " Sent"
notification_symbol = "*"
current_window = 0

252
input_handlers.py Normal file
View File

@@ -0,0 +1,252 @@
import curses
import ipaddress
from ui.colors import get_color
def get_user_input(prompt):
# Calculate the dynamic height and width for the input window
height = 7 # Fixed height for input prompt
width = 60
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create a new window for user input
input_win = curses.newwin(height, width, start_y, start_x)
input_win.bkgd(get_color("background"))
input_win.attrset(get_color("window_frame"))
input_win.border()
# Display the prompt
input_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
input_win.addstr(3, 2, "Enter value: ", get_color("settings_default"))
input_win.refresh()
curses.curs_set(1)
user_input = ""
while True:
key = input_win.getch(3, 15 + len(user_input)) # Adjust cursor position dynamically
if key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
curses.curs_set(0)
return None # Exit without returning a value
elif key == ord('\n'): # Enter key
break
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace
user_input = user_input[:-1]
input_win.addstr(3, 15, " " * (len(user_input) + 1), get_color("settings_default")) # Clear the line
input_win.addstr(3, 15, user_input, get_color("settings_default"))
else:
user_input += chr(key)
input_win.addstr(3, 15, user_input, get_color("settings_default"))
curses.curs_set(0)
# Clear the input window
input_win.erase()
input_win.refresh()
return user_input
def get_bool_selection(message, current_value):
message = "Select True or False:" if None else message
cvalue = current_value
options = ["True", "False"]
selected_index = 0 if current_value == "True" else 1
height = 7
width = 60
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
bool_win = curses.newwin(height, width, start_y, start_x)
bool_win.bkgd(get_color("background"))
bool_win.attrset(get_color("window_frame"))
bool_win.keypad(True)
bool_win.erase()
bool_win.border()
bool_win.addstr(1, 2, message, get_color("settings_default", bold=True))
for idx, option in enumerate(options):
if idx == selected_index:
bool_win.addstr(idx + 3, 4, option, get_color("settings_default", reverse=True))
else:
bool_win.addstr(idx + 3, 4, option, get_color("settings_default"))
bool_win.refresh()
while True:
key = bool_win.getch()
if key == curses.KEY_UP:
if(selected_index > 0):
selected_index = selected_index - 1
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default"))
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default", reverse = True))
elif key == curses.KEY_DOWN:
if(selected_index < len(options) - 1):
selected_index = selected_index + 1
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default"))
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default", reverse = True))
elif key == ord('\n'): # Enter key
return options[selected_index]
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
return cvalue
def get_repeated_input(current_value):
cvalue = current_value
height = 10
width = 60
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
repeated_win = curses.newwin(height, width, start_y, start_x)
repeated_win.bkgd(get_color("background"))
repeated_win.attrset(get_color("window_frame"))
repeated_win.keypad(True) # Enable keypad for special keys
curses.echo()
curses.curs_set(1)
user_input = ""
while True:
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(1, 2, "Enter comma-separated values:", get_color("settings_default", bold=True))
repeated_win.addstr(3, 2, f"Current: {', '.join(map(str, current_value))}", get_color("settings_default"))
repeated_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default"))
repeated_win.refresh()
key = repeated_win.getch()
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow
curses.noecho()
curses.curs_set(0)
return cvalue # Return the current value without changes
elif key == ord('\n'): # Enter key to save and return
curses.noecho()
curses.curs_set(0)
return user_input.split(",") # Split the input into a list
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
user_input = user_input[:-1]
else:
try:
user_input += chr(key) # Append valid character input
except ValueError:
pass # Ignore invalid character inputs
def move_highlight(old_idx, new_idx, options, enum_win, enum_pad):
if old_idx == new_idx:
return # no-op
enum_pad.chgat(old_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default"))
enum_pad.chgat(new_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
enum_win.refresh()
start_index = max(0, new_idx - (enum_win.getmaxyx()[0] - 4))
enum_win.refresh()
enum_pad.refresh(start_index, 0,
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + 4 + enum_win.getmaxyx()[1] - 4)
def get_enum_input(options, current_value):
selected_index = options.index(current_value) if current_value in options else 0
height = min(len(options) + 4, curses.LINES - 2)
width = 60
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
enum_win = curses.newwin(height, width, start_y, start_x)
enum_win.bkgd(get_color("background"))
enum_win.attrset(get_color("window_frame"))
enum_win.keypad(True)
enum_pad = curses.newpad(len(options) + 1, width - 8)
enum_pad.bkgd(get_color("background"))
enum_win.erase()
enum_win.border()
enum_win.addstr(1, 2, "Select an option:", get_color("settings_default", bold=True))
for idx, option in enumerate(options):
if idx == selected_index:
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default", reverse=True))
else:
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default"))
enum_win.refresh()
enum_pad.refresh(0, 0,
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + enum_win.getmaxyx()[1] - 4)
while True:
key = enum_win.getch()
if key == curses.KEY_UP:
old_selected_index = selected_index
selected_index = max(0, selected_index - 1)
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
elif key == curses.KEY_DOWN:
old_selected_index = selected_index
selected_index = min(len(options) - 1, selected_index + 1)
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
elif key == ord('\n'): # Enter key
return options[selected_index]
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
return current_value
def get_fixed32_input(current_value):
cvalue = current_value
current_value = str(ipaddress.IPv4Address(current_value))
height = 10
width = 60
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
fixed32_win = curses.newwin(height, width, start_y, start_x)
fixed32_win.bkgd(get_color("background"))
fixed32_win.attrset(get_color("window_frame"))
fixed32_win.keypad(True)
curses.echo()
curses.curs_set(1)
user_input = ""
while True:
fixed32_win.erase()
fixed32_win.border()
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
fixed32_win.addstr(3, 2, f"Current: {current_value}")
fixed32_win.addstr(5, 2, f"New value: {user_input}")
fixed32_win.refresh()
key = fixed32_win.getch()
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow to cancel
curses.noecho()
curses.curs_set(0)
return cvalue # Return the current value unchanged
elif key == ord('\n'): # Enter key to validate and save
# Validate IP address
octets = user_input.split(".")
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
curses.noecho()
curses.curs_set(0)
fixed32_address = ipaddress.ip_address(user_input)
return int(fixed32_address) # Return the valid IP address
else:
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
fixed32_win.refresh()
curses.napms(1500) # Wait for 1.5 seconds before refreshing
user_input = "" # Clear invalid input
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
user_input = user_input[:-1]
else:
try:
char = chr(key)
if char.isdigit() or char == ".":
user_input += char # Append only valid characters (digits or dots)
except ValueError:
pass # Ignore invalid inputs

47
main.py
View File

@@ -3,37 +3,58 @@
'''
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org
V 1.0.1
V 1.1.3
'''
import curses
from pubsub import pub
import os
import logging
import traceback
from utilities.arg_parser import setup_parser
from utilities.interfaces import initialize_interface
from message_handlers.rx_handler import on_receive
from ui.curses_ui import main_ui, draw_splash
from utilities.utils import get_channels
from utilities.utils import get_channels, get_node_list, get_nodeNum
from db_handler import init_nodedb, load_messages_from_db
import globals
import default_config as config
# Set environment variables for ncurses compatibility
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
os.environ["TERM"] = "screen"
os.environ["LANG"] = "C.UTF-8"
def main(stdscr):
draw_splash(stdscr)
parser = setup_parser()
args = parser.parse_args()
globals.interface = initialize_interface(args)
globals.channel_list = get_channels()
pub.subscribe(on_receive, 'meshtastic.receive')
init_nodedb()
load_messages_from_db()
main_ui(stdscr)
# Configure logging
# Run `tail -f client.log` in another terminal to view live
logging.basicConfig(
filename=config.log_file_path,
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s"
)
def main(stdscr):
try:
draw_splash(stdscr)
parser = setup_parser()
args = parser.parse_args()
globals.interface = initialize_interface(args)
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()
main_ui(stdscr)
except Exception as e:
logging.error("An error occurred: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
raise
if __name__ == "__main__":
curses.wrapper(main)
try:
curses.wrapper(main)
except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc())

View File

@@ -1,68 +1,104 @@
import logging
import time
from meshtastic import BROADCAST_NUM
from utilities.utils import get_node_list, decimal_to_hex, get_nodeNum
from utilities.utils import get_node_list, decimal_to_hex, get_name_from_number
import globals
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
import default_config as config
from datetime import datetime
def on_receive(packet, interface):
global nodes_win
# update packet log
# Update packet log
globals.packet_buffer.append(packet)
if len(globals.packet_buffer) > 20:
# trim buffer to 20 packets
# Trim buffer to 20 packets
globals.packet_buffer = globals.packet_buffer[-20:]
if globals.display_log:
draw_packetlog_win()
try:
if 'decoded' in packet and packet['decoded']['portnum'] == 'NODEINFO_APP':
if 'decoded' not in packet:
return
# Assume any incoming packet could update the last seen time for a node
new_node_list = get_node_list()
if new_node_list != globals.node_list:
globals.node_list = new_node_list
draw_node_list()
if packet['decoded']['portnum'] == 'NODEINFO_APP':
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
get_node_list()
draw_node_list()
maybe_store_nodeinfo_in_db(packet)
elif 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
message_bytes = packet['decoded']['payload']
message_string = message_bytes.decode('utf-8')
refresh_channels = False
refresh_messages = False
if packet.get('channel'):
channel_number = packet['channel']
else:
channel_number = 0
myNodeNum = get_nodeNum()
if packet['to'] == myNodeNum:
if packet['to'] == globals.myNodeNum:
if packet['from'] in globals.channel_list:
pass
else:
globals.channel_list.append(packet['from'])
globals.all_messages[packet['from']] = []
draw_channel_list()
refresh_channels = True
channel_number = globals.channel_list.index(packet['from'])
if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]:
add_notification(channel_number)
refresh_channels = True
else:
refresh_messages = True
# Add received message to the messages list
message_from_id = packet['from']
message_from_string = ""
for node in globals.interface.nodes.values():
if message_from_id == node['num']:
message_from_string = node["user"]["shortName"] + ":" # Get the name using the node ID
break
else:
message_from_string = str(decimal_to_hex(message_from_id)) # If long name not found, use the ID as string
if globals.channel_list[channel_number] in globals.all_messages:
globals.all_messages[globals.channel_list[channel_number]].append((f"{globals.message_prefix} {message_from_string} ", message_string))
else:
globals.all_messages[globals.channel_list[channel_number]] = [(f"{globals.message_prefix} {message_from_string} ", message_string)]
message_from_string = get_name_from_number(message_from_id, type='short') + ":"
if globals.channel_list[channel_number] not in globals.all_messages:
globals.all_messages[globals.channel_list[channel_number]] = []
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
# Retrieve the last timestamp if available
channel_messages = globals.all_messages[globals.channel_list[channel_number]]
if channel_messages:
# Check the last entry for a timestamp
for entry in reversed(channel_messages):
if entry[0].startswith("--"):
last_hour = entry[0].strip("- ").strip()
break
else:
last_hour = None
else:
last_hour = None
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
globals.all_messages[globals.channel_list[channel_number]].append((f"-- {current_hour} --", ""))
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string} ", message_string))
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
draw_channel_list()
draw_messages_window()
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
except KeyError as e:
print(f"Error processing packet: {e}")
logging.error(f"Error processing packet: {e}")

View File

@@ -1,7 +1,11 @@
from datetime import datetime
from meshtastic import BROADCAST_NUM
from db_handler import save_message_to_db
from utilities.utils import get_nodeNum
from db_handler import save_message_to_db, update_ack_nak
from meshtastic.protobuf import mesh_pb2, portnums_pb2
from utilities.utils import get_name_from_number
import globals
import google.protobuf.json_format
import default_config as config
ack_naks = {}
@@ -17,21 +21,102 @@ def onAckNak(packet):
message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1]
confirm_string = " "
ack_type = None
if(packet['decoded']['routing']['errorReason'] == "NONE"):
if(packet['from'] == get_nodeNum()): # Ack "from" ourself means implicit ACK
confirm_string = "[◌]"
if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
confirm_string = "[✓]"
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = "[x]"
confirm_string = config.nak_str
ack_type = "Nak"
globals.all_messages[acknak['channel']][acknak['messageIndex']] = (globals.sent_message_prefix + confirm_string + ": ", message)
globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message)
update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type)
channel_number = globals.channel_list.index(acknak['channel'])
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
draw_messages_window()
def on_response_traceroute(packet):
"""on response for trace route"""
from ui.curses_ui import draw_channel_list, draw_messages_window, add_notification
refresh_channels = False
refresh_messages = False
UNK_SNR = -128 # Value representing unknown SNR
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
msg_str = "Traceroute to:\n"
route_str = get_name_from_number(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response
# SNR list should have one more entry than the route, as the final destination adds its SNR also
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]):
route_str += " --> " + (get_name_from_number(node_num, 'short') or f"{node_num:08x}") \
+ " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)"
# End with origin of response
route_str += " --> " + (get_name_from_number(packet["from"], 'short') or f"{packet['from']:08x}") \
+ " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)"
msg_str += route_str + "\n" # Print the route towards destination
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = get_name_from_number(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += " --> " + (get_name_from_number(node_num, 'short') or f"{node_num:08x}") \
+ " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)"
# End with destination of response (us)
route_str += " --> " + (get_name_from_number(packet["to"], 'short') or f"{packet['to']:08x}") \
+ " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)"
msg_str += route_str + "\n" # Print the route back to us
if(packet['from'] not in globals.channel_list):
globals.channel_list.append(packet['from'])
refresh_channels = True
channel_number = globals.channel_list.index(packet['from'])
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
message_from_string = get_name_from_number(packet['from'], type='short') + ":\n"
if globals.channel_list[channel_number] not in globals.all_messages:
globals.all_messages[globals.channel_list[channel_number]] = []
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string}", msg_str))
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str)
draw_messages_window()
def send_message(message, destination=BROADCAST_NUM, channel=0):
myid = get_nodeNum()
myid = globals.myNodeNum
send_on_channel = 0
channel_id = globals.channel_list[channel]
if isinstance(channel_id, int):
@@ -50,12 +135,44 @@ def send_message(message, destination=BROADCAST_NUM, channel=0):
)
# Add sent message to the messages dictionary
if channel_id in globals.all_messages:
globals.all_messages[channel_id].append((globals.sent_message_prefix + "[…]: ", message))
if channel_id not in globals.all_messages:
globals.all_messages[channel_id] = []
# Handle timestamp logic
current_timestamp = int(datetime.now().timestamp()) # Get current timestamp
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
# Retrieve the last timestamp if available
channel_messages = globals.all_messages[channel_id]
if channel_messages:
# Check the last entry for a timestamp
for entry in reversed(channel_messages):
if entry[0].startswith("--"):
last_hour = entry[0].strip("- ").strip()
break
else:
last_hour = None
else:
globals.all_messages[channel_id] = [(globals.sent_message_prefix + "[…]: ", message)]
last_hour = None
ack_naks[sent_message_data.id] = {'channel' : channel_id, 'messageIndex' : len(globals.all_messages[channel_id]) - 1 }
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
globals.all_messages[channel_id].append((f"-- {current_hour} --", ""))
globals.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message))
save_message_to_db(channel_id, myid, message)
timestamp = save_message_to_db(channel_id, myid, message)
ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp}
def send_traceroute():
r = mesh_pb2.RouteDiscovery()
globals.interface.sendData(
r,
destinationId=globals.node_list[globals.selected_node],
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
wantResponse=True,
onResponse=on_response_traceroute,
channelIndex=0,
hopLimit=3,
)

132
save_to_radio.py Normal file
View File

@@ -0,0 +1,132 @@
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)
def save_changes(interface, menu_path, modified_settings):
"""
Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance
:param menu_path: Current menu path
:param modified_settings: Dictionary of modified settings
"""
try:
if not modified_settings:
logging.info("No changes to save. modified_settings is empty.")
return
node = interface.getNode('^local')
if menu_path[1] == "Radio Settings" or menu_path[1] == "Module Settings":
config_category = menu_path[2].lower() # for radio and module configs
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
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}")
return
elif menu_path[1] == "Channels": # for channel configs
config_category = "Channels"
try:
channel = menu_path[-1]
channel_num = int(channel.split()[-1]) - 1
except (IndexError, ValueError) as e:
channel_num = None
channel = node.channels[channel_num]
for key, value in modified_settings.items():
if key == 'psk': # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value)
elif key == 'position_precision': # Special case: module_settings
channel.settings.module_settings.position_precision = value
else:
setattr(channel.settings, key, value) # Use setattr for other fields
if channel_num == 0:
channel.role = channel_pb2.Channel.Role.PRIMARY
else:
channel.role = channel_pb2.Channel.Role.SECONDARY
node.writeChannel(channel_num)
logging.info(f"Updated Channel {channel_num} in {config_category}")
logging.info(node.channels)
return
else:
config_category = None
for config_item, new_value in modified_settings.items():
# Check if the category exists in localConfig
if hasattr(node.localConfig, config_category):
config_subcategory = getattr(node.localConfig, config_category)
# Check if the category exists in moduleConfig
elif hasattr(node.moduleConfig, config_category):
config_subcategory = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
continue
# Check if the config_item exists in the subcategory
if hasattr(config_subcategory, config_item):
field = getattr(config_subcategory, config_item)
try:
if isinstance(field, (int, float, str, bool)): # Direct field types
setattr(config_subcategory, config_item, new_value)
logging.info(f"Updated {config_category}.{config_item} to {new_value}")
elif isinstance(field, Message): # Handle protobuf sub-messages
if isinstance(new_value, dict): # If new_value is a dictionary
for sub_field, sub_value in new_value.items():
if hasattr(field, sub_field):
setattr(field, sub_field, sub_value)
logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}")
else:
logging.warning(f"Sub-field '{sub_field}' not found in {config_category}.{config_item}")
else:
logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.")
else:
logging.warning(f"Unsupported field type for {config_category}.{config_item}.")
except AttributeError as e:
logging.error(f"Failed to update {config_category}.{config_item}: {e}")
else:
logging.warning(f"Config item '{config_item}' not found in config category '{config_category}'.")
# Write the configuration changes to the node
try:
node.writeConfig(config_category)
logging.info(f"Changes written to config category: {config_category}")
except Exception as e:
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
node.writeConfig(config_category)
logging.info(f"Changes written to config category: {config_category}")
except Exception as e:
logging.error(f"Error saving changes: {e}")

File diff suppressed because it is too large Load Diff

43
ui/colors.py Normal file
View File

@@ -0,0 +1,43 @@
import curses
import default_config as config
COLOR_MAP = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED,
"green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE
}
def setup_colors(reinit=False):
"""
Initialize curses color pairs based on the COLOR_CONFIG.
"""
curses.start_color()
if reinit:
conf = config.initialize_config()
config.assign_config_variables(conf)
for idx, (category, (fg_name, bg_name)) in enumerate(config.COLOR_CONFIG.items(), start=1):
fg = COLOR_MAP.get(fg_name.lower(), curses.COLOR_WHITE)
bg = COLOR_MAP.get(bg_name.lower(), curses.COLOR_BLACK)
curses.init_pair(idx, fg, bg)
config.COLOR_CONFIG[category] = idx
print()
def get_color(category, bold=False, reverse=False, underline=False):
"""
Retrieve a curses color pair with optional attributes.
"""
color = curses.color_pair(config.COLOR_CONFIG[category])
if bold:
color |= curses.A_BOLD
if reverse:
color |= curses.A_REVERSE
if underline:
color |= curses.A_UNDERLINE
return color

View File

@@ -1,33 +1,133 @@
import curses
import textwrap
import globals
from utilities.utils import get_node_list, get_name_from_number, get_channels
from settings import settings
from message_handlers.tx_handler import send_message
from utilities.utils import get_name_from_number, get_channels, get_time_ago
from settings import settings_menu
from message_handlers.tx_handler import send_message, send_traceroute
import ui.dialog
from ui.colors import setup_colors, get_color
import default_config as config
def refresh_all():
for i, box in enumerate([channel_box, messages_box, nodes_box]):
box.attrset(get_color("window_frame_selected") if globals.current_window == i else get_color("window_frame"))
box.box()
box.refresh()
refresh_pad(i)
def draw_node_details():
nodes_snapshot = list(globals.interface.nodes.values())
node = None
for node in nodes_snapshot:
if globals.node_list[globals.selected_node] == node['num']:
break
function_win.erase()
function_win.box()
nodestr = ""
width = function_win.getmaxyx()[1]
node_details_list = [f"{node['user']['longName']}"
if 'user' in node and 'longName' in node['user'] else "",
f"({node['user']['shortName']})"
if 'user' in node and 'shortName' in node['user'] else "",
f" | {node['user']['hwModel']}"
if 'user' in node and 'hwModel' in node['user'] else "",
f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "",
f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "",
f" | SNR: {node['snr']}dB"
if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0)
else "",
]
for s in node_details_list:
if len(nodestr) + len(s) < width:
nodestr = nodestr + s
draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
def draw_function_win():
draw_centered_text_field(function_win,
f"↑→↓← = Select ENTER = Send ` = Settings ^P = Packet Log ESC = Quit",
0, get_color("commands"))
def get_msg_window_lines():
packetlog_height = packetlog_win.getmaxyx()[0] if globals.display_log else 0
return messages_box.getmaxyx()[0] - 2 - packetlog_height
def refresh_pad(window):
win_height = channel_box.getmaxyx()[0]
selected_item = globals.selected_channel
pad = channel_pad
box = channel_box
lines = box.getmaxyx()[0] - 2
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
if(window == 1):
pad = messages_pad
box = messages_box
lines = get_msg_window_lines()
selected_item = globals.selected_message
start_index = globals.selected_message
if globals.display_log:
packetlog_win.box()
packetlog_win.refresh()
if(window == 2):
pad = nodes_pad
box = nodes_box
lines = box.getmaxyx()[0] - 2
selected_item = globals.selected_node
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
pad.refresh(start_index, 0,
box.getbegyx()[0] + 1, box.getbegyx()[1] + 1,
box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2)
def highlight_line(highlight, window, line):
pad = channel_pad
select_len = 0
ch_color = get_color("channel_list")
nd_color = get_color("node_list")
if(window == 2):
pad = nodes_pad
select_len = len(get_name_from_number(globals.node_list[line], "long"))
pad.chgat(line, 1, select_len, nd_color | curses.A_REVERSE if highlight else nd_color)
if(window == 0):
channel = list(globals.all_messages.keys())[line]
win_width = channel_box.getmaxyx()[1]
if(isinstance(channel, int)):
channel = get_name_from_number(channel, type="long")
select_len = min(len(channel), win_width - 4)
if line == globals.selected_channel and highlight == False:
ch_color = get_color("channel_selected")
pad.chgat(line, 1, select_len, ch_color | curses.A_REVERSE if highlight else ch_color)
def add_notification(channel_number):
handle_notification(channel_number, add=True)
globals.notifications.add(channel_number)
def remove_notification(channel_number):
handle_notification(channel_number, add=False)
channel_win.box()
globals.notifications.discard(channel_number)
def handle_notification(channel_number, add=True):
if add:
globals.notifications.add(channel_number) # Add the channel to the notification tracker
else:
globals.notifications.discard(channel_number) # Remove the channel from the notification tracker
def draw_text_field(win, text):
def draw_text_field(win, text, color):
win.border()
win.addstr(1, 1, text)
win.addstr(1, 1, text, color)
def draw_centered_text_field(win, text, y_offset = 0):
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)
win.addstr(y, x, text, color)
win.refresh()
def draw_debug(value):
@@ -35,11 +135,12 @@ def draw_debug(value):
function_win.refresh()
def draw_splash(stdscr):
curses.start_color()
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) # Green text on black background
setup_colors()
curses.curs_set(0)
stdscr.clear()
stdscr.bkgd(get_color("background"))
height, width = stdscr.getmaxyx()
message_1 = "/ Λ"
message_2 = "/ / \\"
@@ -49,136 +150,164 @@ def draw_splash(stdscr):
start_x = width // 2 - len(message_1) // 2
start_x2 = width // 2 - len(message_4) // 2
start_y = height // 2 - 1
stdscr.addstr(start_y, start_x, message_1, curses.color_pair(1) | curses.A_BOLD)
stdscr.addstr(start_y+1, start_x-1, message_2, curses.color_pair(1) | curses.A_BOLD)
stdscr.addstr(start_y+2, start_x-2, message_3, curses.color_pair(1) | curses.A_BOLD)
stdscr.addstr(start_y+4, start_x2, message_4)
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+1, start_x-1, message_2, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+2, start_x-2, message_3, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+4, start_x2, message_4, get_color("splash_text"))
stdscr.attrset(get_color("window_frame"))
stdscr.box()
stdscr.refresh()
curses.napms(500)
def draw_channel_list():
channel_win.clear()
win_height, win_width = channel_win.getmaxyx()
channel_pad.erase()
win_height, win_width = channel_box.getmaxyx()
start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders
for i, channel in enumerate(list(globals.all_messages.keys())[start_index:], start=0):
channel_pad.resize(len(globals.all_messages), channel_box.getmaxyx()[1])
for i, channel in enumerate(list(globals.all_messages.keys())):
# Convert node number to long name if it's an integer
if isinstance(channel, int):
channel = get_name_from_number(channel, type='long')
# Determine whether to add the notification
notification = " " + globals.notification_symbol if start_index + i in globals.notifications else ""
notification = " " + config.notification_symbol if i in globals.notifications else ""
# Truncate the channel name if it's too long to fit in the window
truncated_channel = channel[:win_width - 5] + '-' if len(channel) > win_width - 5 else channel
if i < win_height - 2 : # Check if there is enough space in the window
if start_index + i == globals.selected_channel and globals.current_window == 0:
channel_win.addstr(i + 1, 1, truncated_channel + notification, curses.color_pair(3))
if i == globals.selected_channel:
if globals.current_window == 0:
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list", reverse=True))
remove_notification(globals.selected_channel)
else:
channel_win.addstr(i + 1, 1, truncated_channel + notification, curses.color_pair(4))
channel_win.box()
channel_win.refresh()
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_selected"))
else:
channel_pad.addstr(i, 1, truncated_channel + notification, get_color("channel_list"))
channel_box.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame"))
channel_box.box()
channel_box.attrset((get_color("window_frame")))
channel_box.refresh()
def draw_messages_window():
refresh_pad(0)
def draw_messages_window(scroll_to_bottom = False):
"""Update the messages window based on the selected channel and scroll position."""
messages_win.clear()
messages_pad.erase()
channel = globals.channel_list[globals.selected_channel]
if channel in globals.all_messages:
messages = globals.all_messages[channel]
num_messages = len(messages)
max_messages = messages_win.getmaxyx()[0] - 2 # Max messages that fit in the window
# Adjust for packetlog height if log is visible
if globals.display_log:
packetlog_height = packetlog_win.getmaxyx()[0]
max_messages -= packetlog_height - 1
if max_messages < 1:
max_messages = 1
msg_line_count = 0
# Calculate the scroll position based on the current selection
max_scroll_position = max(0, num_messages - max_messages)
start_index = max(0, min(globals.selected_message, max_scroll_position))
# Display messages starting from the calculated start index
row = 1
for index, (prefix, message) in enumerate(messages[start_index:start_index + max_messages], start=start_index):
row = 0
for (prefix, message) in messages:
full_message = f"{prefix}{message}"
wrapped_lines = textwrap.wrap(full_message, messages_win.getmaxyx()[1] - 2)
wrapped_lines = textwrap.wrap(full_message, messages_box.getmaxyx()[1] - 2)
msg_line_count += len(wrapped_lines)
messages_pad.resize(msg_line_count, messages_box.getmaxyx()[1])
for line in wrapped_lines:
# Highlight the row if it's the selected message
if index == globals.selected_message and globals.current_window == 1:
color = curses.color_pair(3) # Highlighted row color
if prefix.startswith("--"):
color = get_color("timestamps")
elif prefix.startswith(config.sent_message_prefix):
color = get_color("tx_messages")
else:
color = curses.color_pair(1) if prefix.startswith(globals.sent_message_prefix) else curses.color_pair(2)
messages_win.addstr(row, 1, line, color)
color = get_color("rx_messages")
messages_pad.addstr(row, 1, line, color)
row += 1
messages_win.box()
messages_win.refresh()
messages_box.attrset(get_color("window_frame_selected") if globals.current_window == 1 else get_color("window_frame"))
messages_box.box()
messages_box.attrset(get_color("window_frame"))
messages_box.refresh()
if(scroll_to_bottom):
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
else:
globals.selected_message = max(min(globals.selected_message, msg_line_count - get_msg_window_lines()), 0)
refresh_pad(1)
draw_packetlog_win()
def draw_node_list():
nodes_win.clear()
win_height = nodes_win.getmaxyx()[0]
nodes_pad.erase()
win_height = nodes_box.getmaxyx()[0]
start_index = max(0, globals.selected_node - (win_height - 3)) # Calculate starting index based on selected node and window height
for i, node in enumerate(get_node_list()[start_index:], start=1):
if i < win_height - 1 : # Check if there is enough space in the window
if globals.selected_node + 1 == start_index + i and globals.current_window == 2:
nodes_win.addstr(i, 1, get_name_from_number(node, "long"), curses.color_pair(3))
else:
nodes_win.addstr(i, 1, get_name_from_number(node, "long"), curses.color_pair(4))
nodes_pad.resize(len(globals.node_list), nodes_box.getmaxyx()[1])
nodes_win.box()
nodes_win.refresh()
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_number(node, "long"), get_color("node_list", reverse=True))
else:
nodes_pad.addstr(i, 1, get_name_from_number(node, "long"), get_color("node_list"))
nodes_box.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame"))
nodes_box.box()
nodes_box.attrset(get_color("window_frame"))
nodes_box.refresh()
def select_channels(direction):
channel_list_length = len(globals.channel_list)
globals.selected_channel += direction
refresh_pad(2)
if globals.selected_channel < 0:
globals.selected_channel = channel_list_length - 1
elif globals.selected_channel >= channel_list_length:
globals.selected_channel = 0
def select_channel(idx):
old_selected_channel = globals.selected_channel
globals.selected_channel = max(0, min(idx, len(globals.channel_list) - 1))
draw_messages_window(True)
draw_channel_list()
draw_messages_window()
# For now just re-draw channel list when clearing notifications, we can probably make this more efficient
if globals.selected_channel in globals.notifications:
remove_notification(globals.selected_channel)
draw_channel_list()
return
highlight_line(False, 0, old_selected_channel)
highlight_line(True, 0, globals.selected_channel)
refresh_pad(0)
def select_messages(direction):
messages_length = len(globals.all_messages[globals.channel_list[globals.selected_channel]])
def scroll_channels(direction):
new_selected_channel = globals.selected_channel + direction
if new_selected_channel < 0:
new_selected_channel = len(globals.channel_list) - 1
elif new_selected_channel >= len(globals.channel_list):
new_selected_channel = 0
select_channel(new_selected_channel)
def scroll_messages(direction):
globals.selected_message += direction
if globals.selected_message < 0:
globals.selected_message = messages_length - 1
elif globals.selected_message >= messages_length:
globals.selected_message = 0
msg_line_count = messages_pad.getmaxyx()[0]
globals.selected_message = max(0, min(globals.selected_message, msg_line_count - get_msg_window_lines()))
draw_messages_window()
refresh_pad(1)
def select_nodes(direction):
node_list_length = len(get_node_list())
globals.selected_node += direction
def select_node(idx):
old_selected_node = globals.selected_node
globals.selected_node = max(0, min(idx, len(globals.node_list) - 1))
if globals.selected_node < 0:
globals.selected_node = node_list_length - 1
elif globals.selected_node >= node_list_length:
globals.selected_node = 0
highlight_line(False, 2, old_selected_node)
highlight_line(True, 2, globals.selected_node)
refresh_pad(2)
draw_node_list()
draw_node_details()
def scroll_nodes(direction):
new_selected_node = globals.selected_node + direction
if new_selected_node < 0:
new_selected_node = len(globals.node_list) - 1
elif new_selected_node >= len(globals.node_list):
new_selected_node = 0
select_node(new_selected_node)
def draw_packetlog_win():
@@ -186,7 +315,7 @@ def draw_packetlog_win():
span = 0
if globals.display_log:
packetlog_win.clear()
packetlog_win.erase()
height, width = packetlog_win.getmaxyx()
for column in columns[:-1]:
@@ -194,7 +323,7 @@ def draw_packetlog_win():
# Add headers
headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}"
packetlog_win.addstr(1, 1, headers[:width - 2],curses.A_UNDERLINE) # Truncate headers if they exceed window width
packetlog_win.addstr(1, 1, headers[:width - 2],get_color("log_header", underline=True)) # Truncate headers if they exceed window width
for i, packet in enumerate(reversed(globals.packet_buffer)):
if i >= height - 3: # Skip if exceeds the window height
@@ -218,25 +347,18 @@ def draw_packetlog_win():
logString = logString[:width - 3]
# Add to the window
packetlog_win.addstr(i + 2, 1, logString)
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
packetlog_win.attrset(get_color("window_frame"))
packetlog_win.box()
packetlog_win.refresh()
def main_ui(stdscr):
global messages_win, nodes_win, channel_win, function_win, packetlog_win
global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win
stdscr.keypad(True)
get_channels()
# Initialize colors
curses.start_color()
curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(5, curses.COLOR_RED, curses.COLOR_BLACK)
# Calculate window max dimensions
height, width = stdscr.getmaxyx()
@@ -246,88 +368,191 @@ def main_ui(stdscr):
nodes_width = 5 * (width // 16)
messages_width = width - channel_width - nodes_width
channel_win = curses.newwin(height - 6, channel_width, 3, 0)
messages_win = curses.newwin(height - 6, messages_width, 3, channel_width)
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
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)
entry_win.bkgd(get_color("background"))
channel_box.bkgd(get_color("background"))
messages_box.bkgd(get_color("background"))
nodes_box.bkgd(get_color("background"))
# 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)
messages_pad.bkgd(get_color("background"))
nodes_pad.bkgd(get_color("background"))
channel_pad.bkgd(get_color("background"))
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)
draw_centered_text_field(function_win, f"↑→↓← = Select ENTER = Send ` = Settings / = Toggle Log ESC = Quit")
function_win.bkgd(get_color("background"))
packetlog_win.bkgd(get_color("background"))
# Enable scrolling for messages and nodes windows
messages_win.scrollok(True)
nodes_win.scrollok(True)
channel_win.scrollok(True)
draw_channel_list()
draw_node_list()
draw_messages_window()
draw_function_win()
# Draw boxes around windows
channel_win.box()
# Set the normal frame color for the channel box
channel_box.attrset(get_color("window_frame"))
channel_box.box()
# Draw boxes for other windows
entry_win.attrset(get_color("window_frame"))
entry_win.box()
messages_win.box()
nodes_win.box()
function_win.box()
nodes_box.attrset(get_color("window_frame"))
nodes_box.box()
messages_box.attrset(get_color("window_frame"))
messages_box.box()
function_win.attrset(get_color("window_frame"))
function_win.box()
# Refresh all windows
entry_win.refresh()
messages_win.refresh()
nodes_win.refresh()
channel_win.refresh()
function_win.refresh()
channel_box.refresh()
function_win.refresh()
nodes_box.refresh()
messages_box.refresh()
input_text = ""
entry_win.keypad(True)
curses.curs_set(1)
draw_channel_list()
draw_node_list()
draw_messages_window(True)
while True:
draw_text_field(entry_win, f"Input: {input_text}")
draw_text_field(entry_win, f"Input: {input_text[-(width - 10):]}", get_color("input"))
# Get user input from entry window
entry_win.move(1, len(input_text) + 8)
char = entry_win.getch()
char = entry_win.get_wch()
# draw_debug(f"Keypress: {char}")
if char == curses.KEY_UP:
if globals.current_window == 0:
select_channels(-1)
globals.selected_message = len(globals.all_messages[globals.channel_list[globals.selected_channel]]) - 1
scroll_channels(-1)
elif globals.current_window == 1:
select_messages(-1)
scroll_messages(-1)
elif globals.current_window == 2:
select_nodes(-1)
scroll_nodes(-1)
elif char == curses.KEY_DOWN:
if globals.current_window == 0:
select_channels(1)
globals.selected_message = len(globals.all_messages[globals.channel_list[globals.selected_channel]]) - 1
scroll_channels(1)
elif globals.current_window == 1:
select_messages(1)
scroll_messages(1)
elif globals.current_window == 2:
select_nodes(1)
scroll_nodes(1)
elif char == curses.KEY_LEFT:
globals.current_window = (globals.current_window - 1) % 3
draw_channel_list()
draw_node_list()
draw_messages_window()
elif char == curses.KEY_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_RIGHT:
globals.current_window = (globals.current_window + 1) % 3
draw_channel_list()
draw_node_list()
draw_messages_window()
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 == 27:
elif char == chr(27):
break
elif char == curses.KEY_ENTER or char == 10 or char == 13:
# 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
refresh_all()
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
if globals.current_window == 2:
node_list = get_node_list()
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]] = []
@@ -338,19 +563,18 @@ def main_ui(stdscr):
draw_node_list()
draw_channel_list()
draw_messages_window()
draw_messages_window(True)
else:
elif len(input_text) > 0:
# Enter key pressed, send user input as message
send_message(input_text, channel=globals.selected_channel)
draw_messages_window()
draw_messages_window(True)
# Clear entry window and reset input text
input_text = ""
entry_win.clear()
# entry_win.refresh()
entry_win.erase()
elif char == curses.KEY_BACKSPACE or char == 127:
elif char in (curses.KEY_BACKSPACE, chr(127)):
if input_text:
input_text = input_text[:-1]
y, x = entry_win.getyx()
@@ -359,20 +583,26 @@ def main_ui(stdscr):
entry_win.move(y, x - 1)
entry_win.refresh()
elif char == 96:
curses.curs_set(0) # Hide cursor
settings(stdscr)
curses.curs_set(1) # Show cursor again
elif char == "`": # ` Launch the settings interface
curses.curs_set(0)
settings_menu(stdscr, globals.interface)
curses.curs_set(1)
refresh_all()
elif char == 47:
elif char == chr(16):
# Display packet log
if globals.display_log is False:
globals.display_log = True
draw_messages_window()
draw_messages_window(True)
else:
globals.display_log = False
packetlog_win.clear()
draw_messages_window()
packetlog_win.erase()
draw_messages_window(True)
else:
# Append typed character to input text
input_text += chr(char)
if(isinstance(char, str)):
input_text += char
else:
input_text += chr(char)

40
ui/dialog.py Normal file
View File

@@ -0,0 +1,40 @@
import curses
def dialog(stdscr, title, message):
height, width = stdscr.getmaxyx()
# Calculate dialog dimensions
max_line_lengh = 0
message_lines = message.splitlines()
for l in message_lines:
max_line_length = max(len(l), max_line_lengh)
dialog_width = max(len(title) + 4, max_line_length + 4)
dialog_height = len(message_lines) + 4
x = (width - dialog_width) // 2
y = (height - dialog_height) // 2
# Create dialog window
win = curses.newwin(dialog_height, dialog_width, y, x)
win.border(0)
# Add title
win.addstr(0, 2, title)
# Add message
for i, l in enumerate(message_lines):
win.addstr(2 + i, 2, l)
# Add button
win.addstr(dialog_height - 2, (dialog_width - 4) // 2, " Ok ", curses.color_pair(1) | curses.A_REVERSE)
# Refresh dialog window
win.refresh()
# Get user input
while True:
char = win.getch()
# Close dialog with enter, space, or esc
if char in(curses.KEY_ENTER, 10, 13, 32, 27):
win.erase()
win.refresh()
return

97
ui/menus.py Normal file
View File

@@ -0,0 +1,97 @@
from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2
from save_to_radio import settings_reboot, settings_factory_reset, settings_reset_nodedb, settings_shutdown
import logging, traceback
def extract_fields(message_instance, current_config=None):
if isinstance(current_config, dict): # Handle dictionaries
return {key: (None, current_config.get(key, "Not Set")) for key in current_config}
if not hasattr(message_instance, "DESCRIPTOR"):
return {}
menu = {}
fields = message_instance.DESCRIPTOR.fields
for field in fields:
if field.name in {"sessionkey", "channel_num", "id"}: # Skip certain fields
continue
if field.message_type: # Nested message
nested_instance = getattr(message_instance, field.name)
nested_config = getattr(current_config, field.name, None) if current_config else None
menu[field.name] = extract_fields(nested_instance, nested_config)
elif field.enum_type: # Handle enum fields
current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set"
if isinstance(current_value, int): # If the value is a number, map it to its name
enum_value = field.enum_type.values_by_number.get(current_value)
if enum_value: # Check if the enum value exists
current_value_name = f"{enum_value.name}"
else:
current_value_name = f"Unknown ({current_value})"
menu[field.name] = (field, current_value_name)
else:
menu[field.name] = (field, current_value) # Non-integer values
else: # Handle other field types
current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set"
menu[field.name] = (field, current_value)
return menu
def generate_menu_from_protobuf(interface):
# Function to generate the menu structure from protobuf messages
menu_structure = {"Main Menu": {}}
# Add User Settings
current_node_info = interface.getMyNodeInfo() if interface else None
if current_node_info:
current_user_config = current_node_info.get("user", None)
if current_user_config and isinstance(current_user_config, dict):
menu_structure["Main Menu"]["User Settings"] = {
"longName": (None, current_user_config.get("longName", "Not Set")),
"shortName": (None, current_user_config.get("shortName", "Not Set")),
"isLicensed": (None, current_user_config.get("isLicensed", "False"))
}
else:
logging.info("User settings not found in Node Info")
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
else:
logging.info("Node Info not available")
menu_structure["Main Menu"]["User Settings"] = "Node Info not available"
# Add Channels
channel = channel_pb2.ChannelSettings()
menu_structure["Main Menu"]["Channels"] = {}
if interface:
for i in range(8):
current_channel = interface.localNode.getChannelByChannelIndex(i)
if current_channel:
channel_config = extract_fields(channel, current_channel.settings)
menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config
# Add Radio Settings
radio = config_pb2.Config()
current_radio_config = interface.localNode.localConfig if interface else None
menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config)
# Add Module Settings
module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
# Add App Settings
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
# Add additional settings options
menu_structure["Main Menu"]["Reboot"] = settings_reboot
menu_structure["Main Menu"]["Reset Node DB"] = settings_reset_nodedb
menu_structure["Main Menu"]["Shutdown"] = settings_shutdown
menu_structure["Main Menu"]["Factory Reset"] = settings_factory_reset
# Add Exit option
menu_structure["Main Menu"]["Exit"] = None
return menu_structure

385
user_config.py Normal file
View File

@@ -0,0 +1,385 @@
import curses
import json
import os
from ui.colors import get_color, setup_colors
from default_config import format_json_single_line_arrays, loaded_config
width = 60
save_option_text = "Save Changes"
def edit_color_pair(key, current_value):
"""
Allows the user to select a foreground and background color for a key.
"""
colors = [" ", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
fg_color = select_color_from_list(f"Select Foreground Color for {key}", current_value[0], colors)
bg_color = select_color_from_list(f"Select Background Color for {key}", current_value[1], colors)
return [fg_color, bg_color]
def select_color_from_list(prompt, current_color, colors):
"""
Displays a scrollable list of colors for the user to choose from using a pad.
"""
selected_index = colors.index(current_color) if current_color in colors else 0
height = min(len(colors) + 5, curses.LINES - 2)
width = 60
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
color_win = curses.newwin(height, width, start_y, start_x)
color_win.bkgd(get_color("background"))
color_win.attrset(get_color("window_frame"))
color_win.keypad(True)
color_pad = curses.newpad(len(colors) + 1, width - 8)
color_pad.bkgd(get_color("background"))
# Render header
color_win.clear()
color_win.border()
color_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
# Render color options on the pad
for idx, color in enumerate(colors):
if idx == selected_index:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True))
else:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
# Initial refresh
color_win.refresh()
color_pad.refresh(0, 0,
color_win.getbegyx()[0] + 3, color_win.getbegyx()[1] + 4,
color_win.getbegyx()[0] + color_win.getmaxyx()[0] - 2, color_win.getbegyx()[1] + color_win.getmaxyx()[1] - 4)
while True:
key = color_win.getch()
if key == curses.KEY_UP:
if selected_index > 0:
selected_index -= 1
elif key == curses.KEY_DOWN:
if selected_index < len(colors) - 1:
selected_index += 1
elif key == curses.KEY_RIGHT or key == ord('\n'):
return colors[selected_index]
elif key == curses.KEY_LEFT or key == 27: # ESC key
return current_color
# Refresh the pad with updated selection and scroll offset
for idx, color in enumerate(colors):
if idx == selected_index:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True))
else:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
color_win.refresh()
color_pad.refresh(0, 0,
color_win.getbegyx()[0] + 3, color_win.getbegyx()[1] + 4,
color_win.getbegyx()[0] + color_win.getmaxyx()[0] - 2, color_win.getbegyx()[1] + color_win.getmaxyx()[1] - 4)
def edit_value(key, current_value):
width = 60
height = 10
input_width = width - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create a centered window
edit_win = curses.newwin(height, width, start_y, start_x)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
# Display instructions
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = width - 4 # Account for border and padding
wrapped_lines = [current_value[i:i+wrap_width] for i in range(0, len(current_value), wrap_width)]
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
edit_win.refresh()
# Handle theme selection dynamically
if key == "theme":
# Load theme names dynamically from the JSON
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
return select_color_from_list("Select Theme", current_value, theme_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1)
user_input = ""
scroll_offset = 0 # Determines which part of the text is visible
while True:
visible_text = user_input[scroll_offset:scroll_offset + input_width] # Only show what fits
edit_win.addstr(7, 13, " " * input_width, get_color("settings_default")) # Clear previous text
edit_win.addstr(7, 13, visible_text, get_color("settings_default")) # Display text
edit_win.refresh()
edit_win.move(7, 13 + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
key = edit_win.get_wch()
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
curses.curs_set(0)
return current_value # Exit without returning a value
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
if user_input: # Only process if there's something to delete
user_input = user_input[:-1]
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
scroll_offset -= 1 # Move back if text is shorter than scrolled area
else:
if isinstance(key, str):
user_input += key
else:
user_input += chr(key)
if len(user_input) > input_width: # Scroll if input exceeds visible area
scroll_offset += 1
curses.curs_set(0)
return user_input if user_input else current_value
def render_menu(current_data, menu_path, selected_index):
"""
Render the configuration menu with a Save button directly added to the window.
"""
# Determine menu items based on the type of current_data
if isinstance(current_data, dict):
options = list(current_data.keys())
elif isinstance(current_data, list):
options = [f"[{i}]" for i in range(len(current_data))]
else:
options = [] # Fallback in case of unexpected data types
# Calculate dynamic dimensions for the menu
num_items = len(options)
height = min(curses.LINES - 2, num_items + 6) # Include space for borders and Save button
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create the window
menu_win = curses.newwin(height, width, start_y, start_x)
menu_win.clear()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
# Display the menu path
header = " > ".join(menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Populate the pad with menu options
for idx, key in enumerate(options):
value = current_data[key] if isinstance(current_data, dict) else current_data[int(key.strip("[]"))]
display_key = f"{key}"[:width // 2 - 2]
display_value = (
f"{value}"[:width // 2 - 8]
)
color = get_color("settings_default", reverse=(idx == selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
# Add Save button to the main window
save_button_position = height - 2
menu_win.addstr(
save_button_position,
(width - len(save_option_text)) // 2,
save_option_text,
get_color("settings_save", reverse=(selected_index == len(options))),
)
# Refresh menu and pad
menu_win.refresh()
menu_pad.refresh(
0,
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
return menu_win, menu_pad, options
def move_highlight(old_idx, new_idx, options, menu_win, menu_pad):
if old_idx == new_idx:
return # no-op
show_save_option = True
max_index = len(options) + (1 if show_save_option else 0) - 1
if show_save_option and old_idx == max_index: # special case un-highlight "Save" option
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option_text)) // 2, len(save_option_text), get_color("settings_save"))
else:
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_default"))
if show_save_option and new_idx == max_index: # special case highlight "Save" option
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option_text)) // 2, len(save_option_text), get_color("settings_save", reverse = True))
else:
menu_pad.chgat(new_idx, 0,menu_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 6))
menu_win.refresh()
menu_pad.refresh(start_index, 0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
menu_win.getbegyx()[1] + 4 + menu_win.getmaxyx()[1] - 4)
def json_editor(stdscr):
menu_path = ["App Settings"]
selected_index = 0 # Track the selected option
file_path = "config.json"
show_save_option = True # Always show the Save button
# Ensure the file exists
if not os.path.exists(file_path):
with open(file_path, "w") as f:
json.dump({}, f)
# Load JSON data
with open(file_path, "r") as f:
original_data = json.load(f)
data = original_data # Reference to the original data
current_data = data # Track the current level of the menu
# Render the menu
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
need_redraw = True
while True:
if(need_redraw):
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
menu_win.refresh()
need_redraw = False
max_index = len(options) + (1 if show_save_option else 0) - 1
key = menu_win.getch()
if key == curses.KEY_UP:
old_selected_index = selected_index
selected_index = max_index if selected_index == 0 else selected_index - 1
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
elif key == curses.KEY_DOWN:
old_selected_index = selected_index
selected_index = 0 if selected_index == max_index else selected_index + 1
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
elif key == ord("\t") and show_save_option:
old_selected_index = selected_index
selected_index = max_index
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
elif key in (curses.KEY_RIGHT, ord("\n")):
need_redraw = True
menu_win.erase()
menu_win.refresh()
if selected_index < len(options): # Handle selection of a menu item
selected_key = options[selected_index]
# Handle nested data
if isinstance(current_data, dict):
if selected_key in current_data:
selected_data = current_data[selected_key]
else:
continue # Skip invalid key
elif isinstance(current_data, list):
selected_data = current_data[int(selected_key.strip("[]"))]
if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair
new_value = edit_color_pair(
selected_key, selected_data)
current_data[selected_key] = new_value
elif isinstance(selected_data, (dict, list)):
# Navigate into nested data
menu_path.append(str(selected_key))
current_data = selected_data
selected_index = 0 # Reset the selected index
else:
# General value editing
new_value = edit_value(selected_key, selected_data)
current_data[selected_key] = new_value
need_redraw = True
else:
# Save button selected
save_json(file_path, data)
stdscr.refresh()
continue
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
need_redraw = True
menu_win.erase()
menu_win.refresh()
# Navigate back in the menu
if len(menu_path) > 1:
menu_path.pop()
current_data = data
for path in menu_path[1:]:
current_data = current_data[path] if isinstance(current_data, dict) else current_data[int(path.strip("[]"))]
selected_index = 0
else:
# Exit the editor
menu_win.clear()
menu_win.refresh()
break
def save_json(file_path, data):
formatted_json = format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
setup_colors(reinit=True)
def main(stdscr):
curses.curs_set(0)
stdscr.keypad(True)
setup_colors()
json_editor(stdscr)
if __name__ == "__main__":
curses.wrapper(main)

View File

@@ -1,6 +1,5 @@
import meshtastic.serial_interface
import meshtastic.tcp_interface
import meshtastic.ble_interface
import logging
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
import globals
def initialize_interface(args):
@@ -12,6 +11,6 @@ def initialize_interface(args):
try:
return meshtastic.serial_interface.SerialInterface(args.port)
except PermissionError as ex:
print("You probably need to add yourself to the `dialout` group to use a serial connection.")
logging.error("You probably need to add yourself to the `dialout` group to use a serial connection.")
if globals.interface.devPath is None:
return meshtastic.tcp_interface.TCPInterface("meshtastic.local")

View File

@@ -1,4 +1,5 @@
import globals
from datetime import datetime
from meshtastic.protobuf import config_pb2
import re
@@ -34,11 +35,13 @@ def get_channels():
return globals.channel_list
def get_node_list():
node_list = []
if globals.interface.nodes:
for node in globals.interface.nodes.values():
node_list.append(node['num'])
return node_list
sorted_nodes = sorted(
globals.interface.nodes.values(),
key = lambda node: (node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0),
reverse = True)
return [node['num'] for node in sorted_nodes]
return []
def get_nodeNum():
myinfo = globals.interface.getMyNodeInfo()
@@ -55,27 +58,48 @@ def convert_to_camel_case(string):
def get_name_from_number(number, type='long'):
name = ""
for node in globals.interface.nodes.values():
nodes_snapshot = list(globals.interface.nodes.values())
for node in nodes_snapshot:
if number == node['num']:
if type == 'long':
name = node['user']['longName']
return name
return node['user']['longName']
elif type == 'short':
name = node['user']['shortName']
return name
return node['user']['shortName']
else:
pass
else:
name = str(decimal_to_hex(number)) # If long name not found, use the ID as string
return name
# If no match is found, use the ID as a string
return str(decimal_to_hex(number))
def sanitize_string(input_str: str) -> str:
"""Check if the string starts with a letter (a-z, A-Z) or an underscore (_), and replace all non-alpha/numeric/underscore characters with underscores."""
def get_time_ago(timestamp):
now = datetime.now()
dt = datetime.fromtimestamp(timestamp)
delta = now - dt
if not re.match(r'^[a-zA-Z_]', input_str):
# If not, add "_"
input_str = '_' + input_str
value = 0
unit = ""
if delta.days > 365:
value = delta.days // 365
unit = "y"
elif delta.days > 30:
value = delta.days // 30
unit = "mon"
elif delta.days > 7:
value = delta.days // 7
unit = "w"
elif delta.days > 0:
value = delta.days
unit = "d"
elif delta.seconds > 3600:
value = delta.seconds // 3600
unit = "h"
elif delta.seconds > 60:
value = delta.seconds // 60
unit = "min"
if len(unit) > 0:
return f"{value} {unit} ago"
return "now"
# Replace special characters with underscores (for database tables)
sanitized_str: str = re.sub(r'[^a-zA-Z0-9_]', '_', input_str)
return sanitized_str