Compare commits

...

157 Commits

Author SHA1 Message Date
pdxlocations
74d19c5ca7 clean-up 2025-05-18 12:17:27 -07:00
pdxlocations
33f7db4cb9 Merge branch 'main' into refactor-chat-ui 2025-05-18 08:34:01 -07:00
pdxlocations
06d151e991 hack fix 2025-05-18 08:21:53 -07:00
pdxlocations
50efa50923 working changes 2025-05-17 23:27:19 -07:00
pdxlocations
cef86ab185 I think it works! 2025-05-17 23:14:37 -07:00
pdxlocations
3d305a22b9 closer changes 2025-05-17 22:33:44 -07:00
pdxlocations
6d45788a5b mostly working changes 2025-04-28 22:20:00 -07:00
pdxlocations
cc416476f5 Merge pull request #174 from rfschmid/patch-1 2025-04-25 21:20:28 -07:00
Russell Schmidt
7fc1cbc3a9 Fix error "No module named 'ui.ui_state'"
Was unable to run locally at tips due to an import not including the package name.
2025-04-23 17:02:47 -05:00
pdxlocations
e0e09aeccf so close 2025-04-20 22:04:41 -07:00
pdxlocations
bfb7ed0278 more almost working changes 2025-04-20 20:59:10 -07:00
pdxlocations
b7a275f725 Almost working changes 2025-04-19 23:12:59 -07:00
pdxlocations
78f0775ad5 Convert Globals to Class (#173)
* init

* convert globals to dataclass

* move lock to app state
2025-04-19 21:37:54 -07:00
pdxlocations
587d79cb93 move lock to app state 2025-04-19 21:22:47 -07:00
pdxlocations
eb79675be0 convert globals to dataclass 2025-04-19 21:17:19 -07:00
pdxlocations
61e1799199 init 2025-04-19 21:13:46 -07:00
pdxlocations
43f0929247 fix down arrow in user settings 2025-04-19 16:23:17 -07:00
pdxlocations
941e081e90 bump version 2025-04-19 16:17:10 -07:00
pdxlocations
2e8af740be fix always showing down arrow in settings 2025-04-19 16:16:12 -07:00
pdxlocations
a95f128d8e bump version 2025-04-16 21:50:41 -07:00
pdxlocations
3361e4d2ce working changes (#172) 2025-04-16 21:19:46 -07:00
pdxlocations
3959f0768b add help option (#171) 2025-04-16 20:49:17 -07:00
pdxlocations
99839a8075 cleanup 2025-04-16 20:34:46 -07:00
pdxlocations
792cd3c259 Formatting with Black (#170) 2025-04-16 18:48:11 -07:00
pdxlocations
b0a84b3ef3 Refactor Navigation Functions (#169)
* working changes

* working changes

* working changes

* not working changes

* working changes

* cleanup
2025-04-16 18:33:51 -07:00
pdxlocations
b59ae94e00 bump version 2025-04-16 11:30:30 -07:00
pdxlocations
d0ada7eb5b add utf-8 read (#166) 2025-04-13 15:17:36 -07:00
pdxlocations
8d9bbac0be Merge pull request #165 from pdxlocations/type-annotations 2025-04-13 15:02:54 -07:00
Ben Lipsey
613eeb4fab working changes 2025-04-13 14:58:31 -07:00
Ben Lipsey
f7b2645dcb working changes 2025-04-13 14:49:00 -07:00
Ben Lipsey
bc5a5951d4 current state 2025-04-13 14:18:52 -07:00
Ben Lipsey
d7eec6de6e current state 2025-04-12 21:53:27 -07:00
Ben Lipsey
8779297424 current state 2025-04-12 21:19:44 -07:00
Ben Lipsey
ccc1399644 current state 2025-04-12 09:46:00 -07:00
Ben Lipsey
f52034e61f current state 2025-04-11 22:22:34 -07:00
pdxlocations
cdd1d89062 fix typo 2025-04-08 21:17:01 -07:00
pdxlocations
c3ff85a646 bump version 2025-04-08 21:14:58 -07:00
pdxlocations
9b8cf19a0c Fix localhost fallback and allow tcp port (#164)
* update interfaces.py

* fix logging

* update

* update

* fix devpath

* fix returns

* changes
2025-04-08 21:05:32 -07:00
pdxlocations
f2e671da7f Update README.md 2025-04-08 18:10:41 -07:00
pdxlocations
3fc1293db1 Update README.md 2025-04-08 17:58:15 -07:00
pdxlocations
4abe9611e3 bump version 2025-04-06 22:03:05 -07:00
pdxlocations
4d20df17fe Update README.md 2025-04-06 21:59:01 -07:00
pdxlocations
3bb57b9420 Merge pull request #163 from pdxlocations:localhost-fallback
Fallback to localhost not meshtastic.local
2025-04-06 21:44:32 -07:00
pdxlocations
e305bb4464 use localhost not meshtastic.local 2025-04-06 21:44:04 -07:00
pdxlocations
636b27cf9b fix typo 2025-04-06 21:28:07 -07:00
pdxlocations
8e500cb305 bump version 2025-04-06 20:19:44 -07:00
pdxlocations
0878937194 correct instructions for launching control 2025-04-06 20:17:03 -07:00
pdxlocations
ac2016322b update en.ini 2025-04-05 22:35:35 -07:00
pdxlocations
031d74a290 Fix options "not set" displaying values 2025-04-05 22:20:19 -07:00
pdxlocations
14913ce5ae fix new_idx 2025-04-05 21:22:19 -07:00
pdxlocations
c9e39d89b0 rename curses_ui 2025-04-05 19:55:33 -07:00
pdxlocations
dc27e9e02f Refactor into MenuState Class (#162)
* rename state

* changes

* not working changes

* working changes

* not working changes

* working changes

* comments
2025-04-05 19:48:38 -07:00
pdxlocations
4f64131d2e Scroll Arrows for User Config (#161)
* almost working

* likely working changes

* fix width and launch

* unused UI state
2025-04-04 22:49:40 -07:00
pdxlocations
a55d68a828 change debug level 2025-04-03 21:26:12 -07:00
pdxlocations
bd41870567 Merge pull request #160 from rfschmid/make-add-remove-favorite-default-to-yes
Make favorite confirmations default to "Yes"
2025-04-03 18:46:29 -07:00
Russell Schmidt
5a722cbf7d Make favorite confirmations default to "Yes"
Putting the highlight on "no" when pushing the dialog and requiring
scrolling to "yes" feels unnecessary.

Change case on yes/no dialogs to be more consistent.
2025-04-03 17:41:45 -05:00
pdxlocations
9cbc2d51f8 Merge pull request #159 from rfschmid/rm-node-from-db
Rm node from db
2025-04-03 09:00:31 -07:00
Russell Schmidt
5ce3e62fdb Merge 'upstream/main' into rm-node-from-db 2025-04-03 07:27:42 -05:00
pdxlocations
5628758de0 add settings to readme 2025-04-02 22:14:24 -07:00
pdxlocations
890a3b6dc4 cant add multiple authors? 2025-04-02 22:12:32 -07:00
pdxlocations
db01d241c7 bump version 2025-04-02 22:04:53 -07:00
pdxlocations
9044d8d380 Merge pull request #158 from pdxlocations:settings-flag
add settings flag
2025-04-02 22:03:56 -07:00
pdxlocations
0288a1d190 add settings flag 2025-04-02 22:03:28 -07:00
pdxlocations
3674afc216 remove version from main 2025-04-02 21:27:36 -07:00
pdxlocations
da24902bd0 Add Authors 2025-04-02 21:24:51 -07:00
pdxlocations
f9bc7f9be9 Merge pull request #157 from rfschmid:show-favorite-ignored-nodes
Color favorite/ignored nodes
2025-04-02 21:16:45 -07:00
pdxlocations
ffd28c02a3 Merge pull request #156 from rfschmid:rename-main-__main__
Rename main to __main__
2025-04-02 21:14:15 -07:00
Russell Schmidt
d22b3abc2f Make removing node from DB work
Since the Python API doesn't update the nodes table itself, we can just
modify it ourselves. This fixes removing a node so it doesn't just pop
right back up immediately and seems to actually work now.
2025-04-02 15:30:33 -05:00
Russell Schmidt
3c9b81f391 Merge branch 'rename-main-__main__' into rm-node-from-db 2025-04-02 13:17:28 -05:00
Russell Schmidt
ecc360dba9 Color favorite/ignored nodes
Show favorite nodes in color node_favorite (green by default) and
ignored nodes in color node_favorite (red by default). Sort ignored
nodes at the bottom of the node list.
2025-04-02 12:16:15 -05:00
Russell Schmidt
696370308f Rename main to __main__
Most commonly, the __main__.py file is used to provide a command-line
interface for a package. __main__.py will be executed when the package
itself is invoked directly from the command line using the -m flag.
2025-04-02 12:05:01 -05:00
pdxlocations
5999deac1a bump version 2025-04-01 22:07:21 -07:00
pdxlocations
492c1d30d6 Merge pull request #149 from pdxlocations/pyproject-update
add home page
2025-04-01 22:05:38 -07:00
pdxlocations
9e3b684a5f add home page 2025-04-01 22:04:16 -07:00
pdxlocations
25f388ed23 Merge pull request #145 from rfschmid/fix-updating-data-for-nodes-not-working 2025-04-01 21:57:29 -07:00
pdxlocations
07fbdb92e3 Merge pull request #147 from rfschmid/add-ignore-node-support 2025-04-01 21:31:37 -07:00
Russell Schmidt
7c4cc1dd2f Merge 'upstream/main' into fix-updating-data-for-nodes-not-working 2025-04-01 22:52:39 -05:00
Russell Schmidt
06ce9f7ac2 Merge 'upstream/main' into add-ignore-node-support 2025-04-01 22:43:23 -05:00
pdxlocations
f4115e48ad Merge pull request #148 from pdxlocations:poetry
Package for Poetry and Pypi
2025-04-01 15:53:20 -07:00
pdxlocations
857d8d0c04 update readme 2025-04-01 15:52:57 -07:00
pdxlocations
ec0554df14 working changes 2025-04-01 14:58:35 -07:00
pdxlocations
372204a684 mcontact -> contact 2025-04-01 14:57:51 -07:00
Russell Schmidt
8ff55c3de9 Add ignore node support
Press Ctrl+G to ignore/unignore a node.
2025-03-31 21:56:23 -05:00
Russell Schmidt
d9088ccd68 Add favorite node support
Press Ctrl+F to favorite/unfavorite a node. Favorite nodes always appear
at the top of the node list
2025-03-31 21:35:15 -05:00
Russell Schmidt
db8496b2e3 Fix updating data on existing nodes
Since 4bc1654 changed the defaults of the parameters to
update_node_info_in_db(), any call to that funciton that didn't specify
a value for chat_archived would cause chat_archived to be set to 0,
because 0 is not None, we wouldn't preserve the existing value stored in
the DB. Update to use None paramters so we can tell what the caller
specified and did not specify again.
2025-03-31 19:23:15 -05:00
pdxlocations
2b0f6515af update workflow 2025-03-30 19:15:59 -07:00
pdxlocations
4cd7c4e24d change email 2025-03-30 16:28:26 -07:00
pdxlocations
92a30ad8b0 flix user config location 2025-03-27 21:14:51 -07:00
pdxlocations
2960553fef rm extra file 2025-03-27 21:08:26 -07:00
pdxlocations
37c5a7dbc3 fix some imports 2025-03-27 21:07:15 -07:00
pdxlocations
25240f211b add release.yaml 2025-03-27 20:55:50 -07:00
pdxlocations
c236a386a5 mcontact 2025-03-26 22:30:35 -07:00
pdxlocations
66a9954149 mtcontact to mcontact 2025-03-26 22:27:03 -07:00
pdxlocations
3df012dd69 add launch.json 2025-03-26 21:59:33 -07:00
vidplace7
6d204581ce Package with poetry 2025-03-25 09:00:37 -04:00
pdxlocations
3be31698df remove fox references 2025-03-24 19:14:02 -07:00
pdxlocations
140d794213 Merge pull request #140 from rfschmid/fix-crash-sending-long-message 2025-03-18 10:28:38 -07:00
Russell Schmidt
8e6edf8e83 Fix crash sending long message
Don't move the input window itself when restoring the cursor to the
input window - just put the cursor there and refresh.
2025-03-18 11:58:00 -05:00
pdxlocations
c97942d35d attribution 2025-03-17 16:54:50 -07:00
pdxlocations
b9cecaea31 config filepath cleanup 2025-03-17 16:25:26 -07:00
pdxlocations
4bc1654eed fix noon bug (#138) 2025-03-11 10:28:06 -07:00
pdxlocations
ee5f2fa4d4 scroll arrows (#137) 2025-03-10 18:19:10 -07:00
pdxlocations
04381585ab restore app settings 2025-03-08 18:40:01 -08:00
pdxlocations
3fc0495fb1 natural scrolling 2025-03-08 18:16:37 -08:00
pdxlocations
1ccd337b35 maybe fix noon bug 2025-03-08 12:30:28 -08:00
pdxlocations
02034e5821 bump version 2025-03-07 16:54:39 -08:00
pdxlocations
6477d8aeea Merge changes from control repo (#136)
* init

* working changes

* working changes

* changes

* working changes
2025-03-07 16:54:04 -08:00
pdxlocations
eb70e591ae break out splash 2025-03-05 12:06:12 -08:00
pdxlocations
8190bdaafa log force exit 2025-02-26 16:08:57 -08:00
pdxlocations
50c07827f1 Suppress errors from the console (#135)
* suppress console messages

* send errors to log

* stderr and stdout
2025-02-26 15:36:01 -08:00
pdxlocations
c43d014417 revert iface checks 2025-02-26 07:55:30 -08:00
pdxlocations
5f88d0e6fc add some interface checks 2025-02-25 21:48:11 -08:00
pdxlocations
fac5c690ae fix region set at boot 2025-02-25 21:30:44 -08:00
pdxlocations
aa8a66ef22 fix config overwrite option 2025-02-22 18:40:20 -08:00
pdxlocations
498be2c859 Merge pull request #133 from pdxlocations:don't-skip-lora-channel-num-2
Restore missing frequency slot to settings
2025-02-21 18:25:06 -08:00
pdxlocations
b086125962 new skip fields check 2025-02-21 18:24:19 -08:00
pdxlocations
f644b92356 use global interface in save 2025-02-20 22:32:17 -08:00
pdxlocations
3db44f4ae3 remove double writeconfig 2025-02-20 21:57:09 -08:00
pdxlocations
8c837e68a0 keep cursor in the input window (#130) 2025-02-18 22:30:42 -08:00
pdxlocations
5dd06624e3 do close interface after region set 2025-02-12 16:08:18 -08:00
pdxlocations
c83ccea4ef use protubuf number when setting region 2025-02-12 15:27:33 -08:00
pdxlocations
d7f0bee54c try setting region earlier 2025-02-12 15:22:02 -08:00
pdxlocations
fb60773ae6 don't close interface after region set 2025-02-12 15:18:58 -08:00
pdxlocations
47ab0a5b9a bump version 2025-02-09 22:09:59 -08:00
pdxlocations
989c3cf44e add region check at startup (#127) 2025-02-09 22:09:15 -08:00
pdxlocations
71aeae4f92 add note in draw_node_list 2025-02-09 20:49:49 -08:00
pdxlocations
34cd21b323 Fix Startup Error with Thread Lock (#126)
* more excuses

* none isn't better than nothing

* more checks

* typo

* refactor

* less is more

* grasping at straws

* more global

* back up

* db snapshot

* try a threading lock

* fix conflict

* lock it down

* sir locks a lot

* sir locks a lilttle less
2025-02-09 06:46:42 -08:00
pdxlocations
e69c51f9c3 Another attempt to fix startup errors (#125)
* more excuses

* none isn't better than nothing

* more checks

* typo

* refactor

* less is more

* grasping at straws

* more global

* back up

* db snapshot
2025-02-08 15:57:54 -08:00
pdxlocations
3c3bf0ad37 rename enum to list 2025-02-08 13:23:30 -08:00
pdxlocations
804f82cbe6 where have all the nodes_pad gone? 2025-02-08 10:03:51 -08:00
pdxlocations
57042d2050 Maybe fix startup error (again) (#124)
* more excuses

* none isn't better than nothing

* more checks

* typo

* refactor

* less is more
2025-02-08 09:14:25 -08:00
pdxlocations
8342753c51 Merge pull request #123 from pdxlocations/fix-startup-error
Maybe Fix Startup Error
2025-02-07 22:18:58 -08:00
pdxlocations
5690329b06 notes 2025-02-07 22:11:53 -08:00
pdxlocations
a080af3e84 add logging 2025-02-07 21:50:14 -08:00
pdxlocations
dd11932a53 make sure nodes_pad exists legitely 2025-02-07 21:44:17 -08:00
pdxlocations
dae71984bc make sure nodes pad is created 2025-02-07 21:01:06 -08:00
pdxlocations
3668d47119 define windows in resize 2025-02-07 20:49:38 -08:00
pdxlocations
fe3980bc5a ok I'll stop 2025-02-07 20:23:47 -08:00
pdxlocations
9c380c18fd maybe this 2025-02-07 20:19:26 -08:00
pdxlocations
30d14a6a9e big test 2025-02-07 18:08:03 -08:00
pdxlocations
bbfe361173 morer testing 2025-02-07 18:03:10 -08:00
pdxlocations
0d6f234191 more testing 2025-02-06 23:10:01 -08:00
pdxlocations
16c8e3032a fix startup error test 2025-02-06 23:06:19 -08:00
pdxlocations
611d59fefe Merge pull request #120 from rfschmid/fix-receiving-traceroute-from-archived-node 2025-02-05 17:31:01 -08:00
Russell Schmidt
651d381c78 Fix receiving traceroute from archived chat
They were invisible
2025-02-05 19:15:18 -06:00
pdxlocations
e7850b9204 add role to node details 2025-02-05 16:31:12 -08:00
pdxlocations
4306971871 switch locked emoji 2025-02-05 16:05:39 -08:00
pdxlocations
ba86108316 Merge pull request #119 from rfschmid/update-main-ui-screenshot
Update README.md main UI screenshot
2025-02-05 10:41:53 -08:00
Russell Schmidt
83393e2a25 Update README.md main UI screenshot 2025-02-05 12:25:20 -06:00
pdxlocations
9073da802d Update Settings Image 2025-02-05 08:16:15 -08:00
pdxlocations
5907807b71 Merge pull request #118 from pdxlocations/add-commands-to-readme
Update ReadMe with Commands
2025-02-05 08:11:22 -08:00
pdxlocations
cc7124b6f5 add commands 2025-02-05 08:10:43 -08:00
pdxlocations
353412be11 Merge pull request #117 from rfschmid:add-node-search-feature
Add channel and node search feature
2025-02-04 17:21:20 -08:00
Russell Schmidt
8382da07a3 Fix indexing if list changes while searching 2025-02-04 19:16:00 -06:00
pdxlocations
01cfe4c681 Merge pull request #116 from rfschmid/add-lock-icon-for-PSK 2025-02-04 16:02:31 -08:00
Russell Schmidt
1675b0a116 Add channel and node search feature
Press Ctrl + / while the nodes or channels window is highlighted to
start search

Type text to search as you type, first matching item will be selected,
starting at current selected index

Press Tab to find next match starting from the current index - search
wraps around if necessary

Press Esc or Enter to exit search mode
2025-02-04 17:34:41 -06:00
Russell Schmidt
b717d46441 Add lock/unlock icon for nodes with/without PSK 2025-02-04 17:23:44 -06:00
40 changed files with 4305 additions and 2292 deletions

143
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: release
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+a[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+b[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
env:
PACKAGE_NAME: "contact"
OWNER: "pdxlocations"
jobs:
details:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.release.outputs.new_version }}
suffix: ${{ steps.release.outputs.suffix }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: actions/checkout@v2
- name: Extract tag and Details
id: release
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}')
SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "")
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
echo "Version is $NEW_VERSION"
echo "Suffix is $SUFFIX"
echo "Tag name is $TAG_NAME"
else
echo "No tag found"
exit 1
fi
check_pypi:
needs: details
runs-on: ubuntu-latest
steps:
- name: Fetch information from PyPI
run: |
response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}")
latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last")
if [ -z "$latest_previous_version" ]; then
echo "Package not found on PyPI."
latest_previous_version="0.0.0"
fi
echo "Latest version on PyPI: $latest_previous_version"
echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV
- name: Compare versions and exit if not newer
run: |
NEW_VERSION=${{ needs.details.outputs.new_version }}
LATEST_VERSION=$latest_previous_version
if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then
echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI."
exit 1
else
echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI."
fi
setup_and_build:
needs: [details, check_pypi]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Install Poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Set project version with Poetry
run: |
poetry version ${{ needs.details.outputs.new_version }}
- name: Install dependencies
run: poetry install --sync --no-interaction
- name: Build source and wheel distribution
run: |
poetry build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
pypi_publish:
name: Upload release to PyPI
needs: [setup_and_build, details]
runs-on: ubuntu-latest
environment:
name: release
permissions:
id-token: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
github_release:
name: Create GitHub Release
needs: [setup_and_build, details]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Create GitHub Release
id: create_release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes

3
.gitignore vendored
View File

@@ -7,4 +7,5 @@ client.db
client.log
settings.log
config.json
default_config.log
default_config.log
dist/

13
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"version": "0.1.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"cwd": "${workspaceFolder}",
"module": "contact.__main__",
"args": []
}
]
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
}
}

View File

@@ -1,17 +1,45 @@
## Contact - A Console UI for Meshtastic
### (Formerly Curses Client for Meshtastic)
#### Powered by Meshtastic.org
### Install with:
```bash
pip install contact
```
This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores.
<img width="846" alt="Screenshot_2024-03-29_at_4 00 29_PM" src="https://github.com/pdxlocations/meshtastic-curses-client/assets/117498748/e99533b7-5c0c-463d-8d5f-6e3cccaeced7">
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
<br><br>
Settings can be accessed within the client or can be run standalone
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
<img width="509" alt="Screenshot 2024-04-15 at 3 39 12PM" src="https://github.com/pdxlocations/meshtastic-curses-client/assets/117498748/37bc57db-fe2d-4ba4-adc8-679b4cb642f9">
<img width="696" alt="Screenshot 2025-04-08 at 6 10 06PM" src="https://github.com/user-attachments/assets/3d5e3964-f009-4772-bd6e-91b907c65a3b" />
## Message Persistence
All messages will saved in a SQLite DB and restored upon relaunch of the app. You may delete `client.db` if you wish to erase all stored messages and node data. If multiple nodes are used, each will independently store data in the database, but the data will not be shared or viewable between nodes.
## Client Configuration
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
## Commands
- `↑→↓←` = Navigate around the UI.
- `ENTER` = Send a message typed in the Input Window, or with the Node List highlighted, select a node to DM
- `` ` `` = Open the Settings dialogue
- `CTRL` + `p` = Hide/show a log of raw received packets.
- `CTRL` + `t` = With the Node List highlighted, send a traceroute to the selected node
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed.
### Search
- Press `CTRL` + `/` while the nodes or channels window is highlighted to start search
- Type text to search as you type, first matching item will be selected, starting at current selected index
- Press Tab to find next match starting from the current index - search wraps around if necessary
- Press Esc or Enter to exit search mode
## Arguments
@@ -22,14 +50,20 @@ You can pass the following arguments to the client:
Optional arguments to specify a device to connect to and how.
- `--port`, `--serial`, `-s`: The port to connect to via serial, e.g. `/dev/ttyUSB0`.
- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP.
- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed.
- `--ble`, `-b`: The BLE device MAC address or name to connect to.
- `--settings`, `--set`, `--control`, `-c`: Launch directly into the settings.
If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost.
### Example Usage
```sh
python main.py --port /dev/ttyUSB0
python main.py --host 192.168.1.1
python main.py --ble BlAddressOfDevice
contact --port /dev/ttyUSB0
contact --host 192.168.1.1
contact --ble BlAddressOfDevice
```
To quickly connect to localhost, use:
```sh
contact -t
```

136
contact/__main__.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org
Meshtastic® is a registered trademark of Meshtastic LLC.
Meshtastic software components are released under various licenses—see GitHub for details.
No warranty is provided. Use at your own risk.
"""
# Standard library
import contextlib
import curses
import io
import logging
import os
import subprocess
import sys
import threading
import traceback
# Third-party
from pubsub import pub
# Local application
import contact.ui.default_config as config
from contact.message_handlers.rx_handler import on_receive
from contact.settings import set_region
from contact.ui.colors import setup_colors
from contact.ui.contact_ui import main_ui
from contact.ui.splash import draw_splash
from contact.utilities.arg_parser import setup_parser
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
from contact.utilities.input_handlers import get_list_input
from contact.utilities.interfaces import initialize_interface
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
from contact.utilities.singleton import ui_state, interface_state, app_state
# ------------------------------------------------------------------------------
# Environment & Logging Setup
# ------------------------------------------------------------------------------
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
os.environ["LANG"] = "C.UTF-8"
os.environ.setdefault("TERM", "xterm-256color")
if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color"
logging.basicConfig(
filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
app_state.lock = threading.Lock()
# ------------------------------------------------------------------------------
# Main Program Logic
# ------------------------------------------------------------------------------
def initialize_globals(args) -> None:
"""Initializes interface and shared globals."""
interface_state.interface = initialize_interface(args)
# Prompt for region if unset
if interface_state.interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(interface_state.interface)
interface_state.interface.close()
interface_state.interface = initialize_interface(args)
interface_state.myNodeNum = get_nodeNum()
ui_state.channel_list = get_channels()
ui_state.node_list = get_node_list()
pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb()
load_messages_from_db()
def main(stdscr: curses.window) -> None:
"""Main entry point for the curses UI."""
output_capture = io.StringIO()
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
draw_splash(stdscr)
args = setup_parser().parse_args()
if getattr(args, "settings", False):
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
logging.info("Initializing interface...")
with app_state.lock:
initialize_globals(args)
logging.info("Starting main UI")
main_ui(stdscr)
except Exception as e:
console_output = output_capture.getvalue()
logging.error("Uncaught exception: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
logging.error("Console output:\n%s", console_output)
raise
def start() -> None:
"""Launch curses wrapper and redirect logs to file."""
if "--help" in sys.argv or "-h" in sys.argv:
setup_parser().print_help()
sys.exit(0)
with open(config.log_file_path, "a", buffering=1) as log_f:
sys.stdout = log_f
sys.stderr = log_f
with contextlib.redirect_stdout(log_f), contextlib.redirect_stderr(log_f):
try:
curses.wrapper(main)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C")
sys.exit(0)
except Exception as e:
logging.error("Fatal error: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1)
if __name__ == "__main__":
start()

View File

@@ -0,0 +1,315 @@
#field_name, "Human readable field name with first word capitalized", "Help text with [warning]warnings[/warning], [note]notes[/note], [underline]underlines[/underline], \033[31mANSI color codes\033[0m and \nline breaks."
[User Settings]
user, "User"
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
[Channels.channel]
title, "Channels"
channel_num, "Channel number", "The index number of this channel."
psk, "PSK", "The channel's encryption key."
name, "Name", "The channel's name."
id, "", ""
uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT server configured on this node."
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
module_settings, "Module settings", "Position precision and Client Mute."
position_precision, "Position precision", "The precision level of location data sent on this channel."
is_client_muted, "", ""
[config.device]
title, "Device"
role, "Role", "For the vast majority of users, the correct choice is CLIENT. See Meshtastic docs for more information."
serial_enabled, "Enable serial console", ""
button_gpio, "Button GPIO", "GPIO pin for user button."
buzzer_gpio, "Buzzer GPIO", "GPIO pin for user buzzer."
rebroadcast_mode, "Rebroadcast mode", "This setting defines the device's behavior for how messages are rebroadcast."
node_info_broadcast_secs, "Nodeinfo broadcast interval", "This is the number of seconds between NodeInfo message broadcasts. Will also send a nodeinfo in response to new nodes on the mesh."
double_tap_as_button_press, "Double tap as button press", "This option will enable a double tap, when a supported accelerometer is attached to the device, to be treated as a button press."
is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note] Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]"
disable_triple_click, "Disable triple button press", ""
tzdef, "Timezone", "Uses the TZ Database format to display the correct local time on the device display and in its logs."
led_heartbeat_disabled, "Disable LED heartbeat", "On certain hardware models, this disables the blinking heartbeat LED."
[config.position]
title, "Position"
position_broadcast_secs, "Position broadcast interval", "If smart broadcast is off we should send our position this often."
position_broadcast_smart_enabled, "Smart position broadcast enabled", "Smart broadcast will send out your position at an increased frequency only if your location has changed enough for a position update to be useful."
fixed_position, "Fixed position", "If set, this use a fixed position. The device will generate GPS updates but use whatever the last lat/lon/alt it saved for the node. Position can be set by an internal GPS or with smartphone GPS."
latitude, "Latitude", ""
longitude, "Longitude", ""
altitude, "Altitude", ""
gps_enabled, "GPS enabled", ""
gps_update_interval, "GPS update interval", "How often we should try to get GPS position (in seconds), or zero for the default of once every 2 minutes, or a very large value (maxint) to update only once at boot."
gps_attempt_time, "GPS attempt time", ""
position_flags, "Position flags", "See Meshtastic docs for more information."
rx_gpio, "GPS RX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the RX pin of a GPS module."
tx_gpio, "GPS TX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the TX pin of a GPS module."
broadcast_smart_minimum_distance, "GPS smart position min distance", "The minimum distance in meters traveled (since the last send) before we can send a position to the mesh if smart broadcast is enabled."
broadcast_smart_minimum_interval_secs, "GPS smart position min interval", "The minimum number of seconds (since the last send) before we can send a position to the mesh if smart broadcast is enabled."
gps_en_gpio, "GPS enable GPIO", ""
gps_mode, "GPS mode", "Configures whether the GPS functionality is enabled, disabled, or not present on the node."
[config.power]
title, "Power"
is_power_saving, "Enable power saving mode", "Automatically shut down a device after this many seconds if power is lost."
on_battery_shutdown_after_secs, "Battery shutdown interval", ""
adc_multiplier_override, "ADC multiplier override", "Ratio of voltage divider for battery pin. Overrides the ADC_MULTIPLIER defined in the firmware device variant file for battery voltage calculation. See Meshtastic docs for more info."
wait_bluetooth_secs, "Bluetooth", "How long to wait before turning off BLE when no bluetooth device is connected."
sds_secs, "Super deep sleep interval", "While in Light Sleep if mesh_sds_timeout_secs is exceeded we will lower into super deep sleep for this value or a button press. 0 for default of one year"
ls_secs, "Light sleep interval", "ESP32 only. In light sleep the CPU is suspended, LoRa radio is on, BLE is off and GPS is on."
min_wake_secs, "Minimum wake interval", "While in light sleep when we receive packets on the LoRa radio we will wake and handle them and stay awake in no Bluetooth mode for this interval in seconds."
device_battery_ina_address, "Device battery INA2xx address", "If an INA-2XX device is auto-detected on one of the I2C buses at the specified address, it will be used as the authoritative source for reading device battery level voltage. Setting is ignored for devices with PMUs (e.g. T-beams)"
powermon_enables, "Power monitor enables", "If non-zero, we want powermon log outputs. With the particular (bitfield) sources enabled."
[config.network]
title, "Network"
wifi_enabled, "Wi-Fi enabled", "Enables or Disables Wi-Fi."
wifi_ssid, "Wi-Fi SSID", "This is your Wi-Fi Network's SSID."
wifi_psk, "Wi-Fi PSK", "This is your Wi-Fi Network's password."
ntp_server, "NTP server", "The network time server used if IP networking is available."
eth_enabled, "Ethernet enabled", "Enables or Disables Ethernet on some hardware models."
address_mode, "IPv4 networking mode", "Set to DHCP by default. Change to STATIC to use a static IP address. Applies to both Ethernet and Wi-Fi."
ipv4_config, "IPv4 configuration", "Advanced network settings"
ip, "IPv4 static address", ""
gateway, "IPv4 gateway", ""
subnet, "IPv4 subnet", ""
dns, "IPv4 DNS server", ""
rsyslog_server, "RSyslog server", ""
enabled_protocols, "Enabled protocols", ""
[config.network.ipv4_config]
title, "IPv4 Config", ""
ip, "IP", ""
gateway, "Gateway", ""
subnet, "Subnet", ""
dns, "DNS", ""
[config.display]
title, "Display"
screen_on_secs, "Screen on duration", "How long the screen remains on in seconds after the user button is pressed or messages are received."
gps_format, "GPS format", "The format used to display GPS coordinates on the device screen."
auto_screen_carousel_secs, "Auto carousel interval", "Automatically toggles to the next page on the screen like a carousel, based on the specified interval in seconds."
compass_north_top, "Always point north", "If set, compass heading on screen outside of the circle will always point north. This feature is off by default and the top of display represents your heading direction, the North indicator will move around the circle."
flip_screen, "Flip screen", "Whether to flip the screen vertically."
units, "Preferred display units", "Switch between METRIC (default) and IMPERIAL units."
oled, "OLED definition", "The type of OLED Controller is auto-detected by default, but can be defined with this setting if the auto-detection fails. For the SH1107, we assume a square display with 128x128 Pixels like the GME128128-1."
displaymode, "Display mode", "DEFAULT, TWOCOLOR, INVERTED or COLOR. TWOCOLOR: intended for OLED displays with first line a different color. INVERTED: will invert bicolor area, resulting in white background headline on monochrome displays."
heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' or 'TWOCOLOR' display mode is used. This setting will make the heading bold, so it is easier to read."
wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button."
compass_orientation, "Compass orientation", "Whether to rotate the compass."
use_12h_clock, "Use 12 hour clock"
[config.device_ui]
title, "Device UI"
version, "Version", ""
screen_brightness, "Screen brightness", ""
screen_timeout, "Screen timeout", ""
screen_lock, "Screen lock", ""
settings_lock, "Settings lock", ""
pin_code, "PIN code", ""
theme, "Theme", ""
alert_enabled, "Alert enabled", ""
banner_enabled, "Banner enabled", ""
ring_tone_id, "Ring tone ID", ""
language, "Language", ""
node_filter, "Node Filter", ""
node_highlight, "Node Highlight", ""
calibration_data, "Calibration Data", ""
map_data, "Map Data", ""
[config.device_ui.node_filter]
title, "Node Filter"
unknown_switch, "Unknown Switch", ""
offline_switch, "Offline Switch", ""
public_key_switch, "Public Key Switch", ""
hops_away, "Hops Away", ""
position_switch, "Position Switch", ""
node_name, "Node Name", ""
channel, "Channel", ""
[config.device_ui.node_highlight]
title, "Node Highlight"
chat_switch, "Chat Switch", ""
position_switch, "Position Switch", ""
telemetry_switch, "Telemetry Switch", ""
iaq_switch, "IAQ Switch", ""
node_name, "Node Name", ""
[config.device_ui.map_data]
title, "Map Data"
home, "Home", ""
style, "Style", ""
follow_gps, "Follow GPS", ""
[config.lora]
title, "LoRa"
use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset."
modem_preset, "Preset", "The default preset will provide a strong mixture of speed and range, for most users."
bandwidth, "Bandwidth", "Width of the frequency 'band' used around the calculated center frequency. Only used if modem preset is disabled."
spread_factor, "Spread factor", "Indicates the number of chirps per symbol. Only used if modem preset is disabled."
coding_rate, "Coding rate", "The proportion of each LoRa transmission that contains actual data - the rest is used for error correction."
frequency_offset, "Frequency offset", "This parameter is for advanced users with advanced test equipment."
region, "Region", "Sets the region for your node. As long as this is not set, the node will display a message and not transmit any packets."
hop_limit, "Hop limit", "The maximum number of intermediate nodes between our node and a node it is sending to. Does not impact received messages.\n[warning]Excessive hop limit increases congestion![/warning]\nMust be between 0-7."
tx_enabled, "Enable TX", "Enables/disables the radio chip. Useful for hot-swapping antennas."
tx_power, "TX power in dBm", "[warning]Setting a 33db radio above 8db will permanently damage it. ERP above 27db violates EU law. ERP above 36db violates US (unlicensed) law.[/warning] If 0, will use the max continuous power legal in region. Must be 0-30 (0=automatic)."
channel_num, "Frequency slot", "Determines the exact frequency the radio transmits and receives. If unset or set to 0, determined automatically by the primary channel name."
override_duty_cycle, "Override duty cycle", "Override the legal transmit time limit to allow unlimited transmit time. [warning]May have legal ramifications.[/warning]"
sx126x_rx_boosted_gain, "Enable SX126X RX boosted gain", "This is an option specific to the SX126x chip series which allows the chip to consume a small amount of additional power to increase RX (receive) sensitivity."
override_frequency, "Override frequency in MHz", "Overrides frequency slot. May have legal ramifications."
pa_fan_disabled, "", ""
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
[config.bluetooth]
title, "Bluetooth"
enabled, "Enabled", "Enables bluetooth. Duh!"
mode, "Pairing mode", "RANDOM_PIN generates a random PIN during runtime. FIXED_PIN uses the fixed PIN that should then be additionally specified. Finally, NO_PIN disables PIN authentication."
fixed_pin, "Fixed PIN", "If your pairing mode is set to FIXED_PIN, the default value is 123456. For all other pairing modes, this number is ignored. A custom integer (6 digits) can be set via the Bluetooth config options."
[config.security]
title, "Security"
public_key, "Public key", "The public key of the device, shared with other nodes on the mesh to allow them to compute a shared secret key for secure communication. Generated automatically to match private key.\n[warning]Don't change this if you don't know what you're doing.[/warning]"
private_key, "Private key", "The private key of the device, used to create a shared key with a remote device for secure communication.\n[warning]This key should be kept confidential.[/warning]\n[note]Setting an invalid key will lead to unexpected behaviors.[/note]"
is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note]Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]"
serial_enabled, "Enable serial console", ""
debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active."
admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes."
admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3."
[module.mqtt]
title, "MQTT"
enabled, "Enabled", "Enables the MQTT module."
address, "Server address", "The server to use for MQTT. If not set, the default public server will be used."
username, "Username", "MQTT Server username to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default public server, this will only be honored if set, otherwise the device will use the default username."
password, "Password", "MQTT password to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default server, this will only be honored if set, otherwise the device will use the default password."
encryption_enabled, "Encryption enabled", "Whether to send encrypted or unencrypted packets to the MQTT server. Unencrypted packets may be useful for external systems that want to consume meshtastic packets. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set."
json_enabled, "JSON enabled", "Enable the sending / consumption of JSON packets on MQTT. These packets are not encrypted, but offer an easy way to integrate with systems that can read JSON. JSON is not supported on the nRF52 platform."
tls_enabled, "TLS enabled", "If true, we attempt to establish a secure connection using TLS."
root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs."
proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings."
map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet."
map_report_settings.publish_interval_secs, "Map report publish interval", "How often we should publish the map report to the MQTT server in seconds. Defaults to 900 seconds (15 minutes)."
map_report_settings.position_precision, "Map report position precision", "The precision to use for the position in the map report. Defaults to a maximum deviation of around 1459m."
[module.serial]
title, "Serial"
enabled, "Enabled", "Enables the module."
echo, "Echo", "If set, any packets you send will be echoed back to your device."
rxd, "Receive GPIO pin", "Set the GPIO pin to the RXD pin you have set up."
txd, "Transmit GPIO pin", "Set the GPIO pin to the TXD pin you have set up."
baud, "Baud rate", "The serial baud rate."
timeout, "Timeout", "The amount of time to wait before we consider your packet as 'done'."
mode, "Mode", "See Meshtastic docs for more information."
override_console_serial_port, "Override console serial port", "If set to true, this will allow Serial Module to control (set baud rate) and use the primary USB serial bus for output. This is only useful for NMEA and CalTopo modes and may behave strangely or not work at all in other modes. Setting TX/RX pins in the Serial Module config will cause this setting to be ignored."
[module.external_notification]
title, "External Notification"
enabled, "Enabled", "Enables the module."
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
output, "", ""
output_vibra, "", ""
output_buzzer, "", ""
active, "Active (general / LED only)", "Specifies whether the external circuit is active when the device's GPIO is low or high. If this is set true, the pin will be pulled active high, false means active low."
alert_message, "Alert when receiving a message (general)", "Specifies if an alert should be triggered when receiving an incoming message."
alert_message_vibra, "Alert vibration on message", "Specifies if a vibration alert should be triggered when receiving an incoming message."
alert_message_buzzer, "Alert buzzer on message", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character."
alert_bell, "Alert when receiving a bell (general)", "Specifies if an alert should be triggered when receiving an incoming bell with an alert bell character."
alert_bell_vibra, "Alert vibration on bell", "Specifies if a vibration alert should be triggered when receiving an incoming message with an alert bell character."
alert_bell_buzzer, "Alert buzzer on bell", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character."
use_pwm, "Use PWM for buzzer", ""
nag_timeout, "Repeat (nag timeout)", "Specifies if the alert should be repeated. If set to a value greater than zero, the alert will be repeated until the user button is pressed or 'value' number of seconds have past."
use_i2s_as_buzzer, "Use i2s as buzzer", ""
[module.store_forward]
title, "Store & Forward"
enabled, "Enabled", "Enables the module."
heartbeat, "Heartbeat", "The Store & Forward Server sends a periodic message onto the network. This allows connected devices to know that a server is in range and listening to received messages. A client like Android, iOS, or Web can (if supported) indicate to the user whether a Store & Forward Server is available."
records, "Records", "Set this to the maximum number of records the server will save. Best to leave this at the default (0) where the module will use 2/3 of your device's available PSRAM. This is about 11,000 records."
history_return_max, "History return max", "Sets the maximum number of messages to return to a client device when it requests the history."
history_return_window, "History return window", "Limits the time period (in minutes) a client device can request."
is_server, "Is server", "Set to true to configure your node with PSRAM as a Store & Forward Server for storing and forwarding messages. This is an alternative to setting the node as a ROUTER and only available since 2.4."
[module.range_test]
title, "Range Test"
enabled, "Enabled", "Enables the module."
sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages."
save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]"
[module.telemetry]
title, "Telemetry"
device_update_interval, "Device metrics update interval", "How often we should send Device Metrics over the mesh in seconds."
environment_update_interval, "Environment metrics update interval", "How often we should send environment (sensor) Metrics over the mesh in seconds."
environment_measurement_enabled, "Environment telemetry enabled", "Enable the Environment Telemetry (Sensors)."
environment_screen_enabled, "Environment screen enabled", "Show the environment telemetry data on the device display."
environment_display_fahrenheit, "Display fahrenheit", "The sensor is always read in Celsius, but the user can opt to display in Fahrenheit (on the device display only) using this setting."
air_quality_enabled, "Air quality enabled", "This option is used to enable/disable the sending of air quality metrics from an attached supported sensor over the mesh network."
air_quality_interval, "Air quality interval", "This option is used to configure the interval in seconds that should be used to send air quality metrics from an attached supported sensor over the mesh network in seconds."
power_measurement_enabled, "Power metrics enabled", "This option is used to enable/disable the sending of power telemetry as gathered by an attached supported voltage/current sensor. Note that this does not need to be enabled to monitor the voltage of the battery."
power_update_interval, "Power metrics interval", "This option is used to configure the interval in seconds that should be used to send power metrics from an attached supported sensor over the mesh network in seconds."
power_screen_enabled, "Power screen enabled", "Show the power telemetry data on the device display."
health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds."
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
[module.canned_message]
title, "Canned Message"
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
inputbroker_pin_press, "Input broker pin press", "GPIO Pin Value (1-39) For encoder Press port."
inputbroker_event_cw, "Input broker event clockwise", "Generate the rotary clockwise event."
inputbroker_event_ccw, "Input broker event counter clockwise", "Generate the rotary counter clockwise event."
inputbroker_event_press, "Input broker event press", "Generate input event on Press of this kind."
updown1_enabled, "Up down encoder enabled", "Enable the up / down encoder."
enabled, "Enabled", "Enables the module."
allow_input_source, "Input source", "Input event sources accepted by the canned message module."
send_bell, "Send bell", "Sends a bell character with each message."
[module.audio]
title, "Audio"
codec2_enabled, "Enabled", "Enables the module."
ptt_pin, "PTT GPIO", "The GPIO to use for the Push-To-Talk button. The default is GPIO 39 on the ESP32."
bitrate, "Audio bitrate/codec mode", "The bitrate to use for audio."
i2s_ws, "I2S word select", "The GPIO to use for the WS signal in the I2S interface."
i2s_sd, "I2S data IN", "The GPIO to use for the SD signal in the I2S interface."
i2s_din, "I2S data OUT", "The GPIO to use for the DIN signal in the I2S interface."
i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface."
[module.remote_hardware]
title, "Remote Hardware"
enabled, "Enabled", "Enables the module."
allow_undefined_pin_access, "Allow undefined pin access", ""
[module.neighbor_info]
title, "Neighbor Info"
enabled, "Enabled", "Enables the module."
update_interval, "Update interval", "How often in seconds the neighbor info is sent to the mesh. This cannot be set lower than 4 hours (14400 seconds). The default is 6 hours (21600 seconds)."
transmit_over_lora, "Transmit over LoRa", "Available from firmware 2.5.13 and higher. By default, neighbor info will only be sent to MQTT and a connected app. If enabled, the neighbor info will be sent on the primary channel over LoRa. Only available when the primary channel is not the public channel with default key and name."
[module.ambient_lighting]
title, "Ambient Lighting"
led_state, "LED state", "Sets the LED to on or Off."
current, "Current", "Sets the current for the LED output. Default is 10."
red, "Red", "Sets the red LED level. Values are 0-255."
green, "Green", "Sets the green LED level. Values are 0-255."
blue, "Blue", "Sets the blue LED level. Values are 0-255."
[module.detection_sensor]
title, "Detection Sensor"
enabled, "Enabled", "Enables the module."
minimum_broadcast_secs, "Minimum broadcast interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
state_broadcast_secs, "State broadcast interval", "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes, When set to 0, only state changes will be broadcasted, Works as a sort of status heartbeat for peace of mind."
send_bell, "Send bell", "Send ASCII bell with alert message. Useful for triggering ext. notification on bell name."
name, "Friendly name", "Used to format the message sent to mesh. Example: A name 'Motion' would result in a message 'Motion detected'. Maximum length of 20 characters."
monitor_pin, "Monitor pin", "The GPIO pin to monitor for state changes."
detection_trigger_type, "Detection triggered high", "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)."
use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin."
[module.paxcounter]
title, "Paxcounter"
enabled, "Enabled", "Enables the module."
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
Wi-Fi_threshold, "", ""
ble_threshold, "", ""

View File

@@ -0,0 +1,125 @@
import logging
import time
from datetime import datetime
from typing import Any, Dict
from contact.utilities.utils import refresh_node_list
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
draw_messages_window,
draw_channel_list,
add_notification,
)
from contact.utilities.db_handler import (
save_message_to_db,
maybe_store_nodeinfo_in_db,
get_name_from_database,
update_node_info_in_db,
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state
def on_receive(packet: Dict[str, Any], interface: Any) -> None:
"""
Handles an incoming packet from a Meshtastic interface.
Args:
packet: The received Meshtastic packet as a dictionary.
interface: The Meshtastic interface instance that received the packet.
"""
with app_state.lock:
# Update packet log
ui_state.packet_buffer.append(packet)
if len(ui_state.packet_buffer) > 20:
# Trim buffer to 20 packets
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
if ui_state.display_log:
draw_packetlog_win()
try:
if "decoded" not in packet:
return
# Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list()
if changed:
draw_node_list()
if packet["decoded"]["portnum"] == "NODEINFO_APP":
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
maybe_store_nodeinfo_in_db(packet)
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
message_bytes = packet["decoded"]["payload"]
message_string = message_bytes.decode("utf-8")
refresh_channels = False
refresh_messages = False
if packet.get("channel"):
channel_number = packet["channel"]
else:
channel_number = 0
if packet["to"] == interface_state.myNodeNum:
if packet["from"] in ui_state.channel_list:
pass
else:
ui_state.channel_list.append(packet["from"])
if packet["from"] not in ui_state.all_messages:
ui_state.all_messages[packet["from"]] = []
update_node_info_in_db(packet["from"], chat_archived=False)
refresh_channels = True
channel_number = ui_state.channel_list.index(packet["from"])
if ui_state.channel_list[channel_number] != ui_state.channel_list[ui_state.selected_channel]:
add_notification(channel_number)
refresh_channels = True
else:
refresh_messages = True
# Add received message to the messages list
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
if ui_state.channel_list[channel_number] not in ui_state.all_messages:
ui_state.all_messages[ui_state.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 = ui_state.all_messages[ui_state.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:
ui_state.all_messages[ui_state.channel_list[channel_number]].append((f"-- {current_hour} --", ""))
ui_state.all_messages[ui_state.channel_list[channel_number]].append(
(f"{config.message_prefix} {message_from_string} ", message_string)
)
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(ui_state.channel_list[channel_number], message_from_id, message_string)
except KeyError as e:
logging.error(f"Error processing packet: {e}")

View File

@@ -0,0 +1,242 @@
from datetime import datetime
from typing import Any, Dict
import google.protobuf.json_format
from meshtastic import BROADCAST_NUM
from meshtastic.protobuf import mesh_pb2, portnums_pb2
from contact.utilities.db_handler import (
save_message_to_db,
update_ack_nak,
get_name_from_database,
is_chat_archived,
update_node_info_in_db,
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
ack_naks: Dict[str, Dict[str, Any]] = {} # requestId -> {channel, messageIndex, timestamp}
# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention
# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462
def onAckNak(packet: Dict[str, Any]) -> None:
"""
Handles incoming ACK/NAK response packets.
"""
from contact.ui.contact_ui import draw_messages_window
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
confirm_string = " "
ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = config.nak_str
ack_type = "Nak"
ui_state.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 = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
draw_messages_window()
def on_response_traceroute(packet: Dict[str, Any]) -> None:
"""
Handle traceroute response packets and render the route visually in the UI.
"""
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
refresh_channels = False
refresh_messages = False
UNK_SNR = -128 # Value representing unknown SNR
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
msg_str = "Traceroute to:\n"
route_str = (
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
# SNR list should have one more entry than the route, as the final destination adds its SNR also
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ "dB)"
)
# End with origin of response
route_str += (
" --> "
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ " ("
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route towards destination
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = (
get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
) # Start with origin of response
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ "dB)"
)
# End with destination of response (us)
route_str += (
" --> "
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ " ("
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route back to us
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
channel_number = ui_state.channel_list.index(packet["from"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
if ui_state.channel_list[channel_number] not in ui_state.all_messages:
ui_state.all_messages[ui_state.channel_list[channel_number]] = []
ui_state.all_messages[ui_state.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(ui_state.channel_list[channel_number], packet["from"], msg_str)
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
"""
Sends a chat message using the selected channel.
"""
myid = interface_state.myNodeNum
send_on_channel = 0
channel_id = ui_state.channel_list[channel]
if isinstance(channel_id, int):
send_on_channel = 0
destination = channel_id
elif isinstance(channel_id, str):
send_on_channel = channel
sent_message_data = interface_state.interface.sendText(
text=message,
destinationId=destination,
wantAck=True,
wantResponse=False,
onResponse=onAckNak,
channelIndex=send_on_channel,
)
# Add sent message to the messages dictionary
if channel_id not in ui_state.all_messages:
ui_state.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 = ui_state.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:
last_hour = None
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
ui_state.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message))
timestamp = save_message_to_db(channel_id, myid, message)
ack_naks[sent_message_data.id] = {
"channel": channel_id,
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
"timestamp": timestamp,
}
def send_traceroute() -> None:
"""
Sends a RouteDiscovery protobuf to the selected node.
"""
r = mesh_pb2.RouteDiscovery()
interface_state.interface.sendData(
r,
destinationId=ui_state.node_list[ui_state.selected_node],
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
wantResponse=True,
onResponse=on_response_traceroute,
channelIndex=0,
hopLimit=3,
)

70
contact/settings.py Normal file
View File

@@ -0,0 +1,70 @@
import contextlib
import curses
import io
import logging
import sys
import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
from contact.utilities.arg_parser import setup_parser
from contact.utilities.interfaces import initialize_interface
def main(stdscr: curses.window) -> None:
output_capture = io.StringIO()
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
draw_splash(stdscr)
curses.curs_set(0)
stdscr.keypad(True)
parser = setup_parser()
args = parser.parse_args()
interface = initialize_interface(args)
if interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(interface)
interface.close()
interface = initialize_interface(args)
stdscr.clear()
stdscr.refresh()
settings_menu(stdscr, interface)
except Exception as e:
console_output = output_capture.getvalue()
logging.error("An error occurred: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
logging.error("Console output before crash:\n%s", console_output)
raise
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
filename=config.log_file_path,
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s",
)
if __name__ == "__main__":
log_file = config.log_file_path
log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes
sys.stdout = log_f
sys.stderr = log_f
with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f):
try:
curses.wrapper(main)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C or Ctrl+X") # Clean exit logging
sys.exit(0) # Ensure a clean exit
except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1) # Exit with an error code

View File

@@ -1,5 +1,5 @@
import curses
import default_config as config
import contact.ui.default_config as config
COLOR_MAP = {
"black": curses.COLOR_BLACK,
@@ -9,10 +9,11 @@ COLOR_MAP = {
"blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE
"white": curses.COLOR_WHITE,
}
def setup_colors(reinit=False):
def setup_colors(reinit: bool = False) -> None:
"""
Initialize curses color pairs based on the COLOR_CONFIG.
"""
@@ -29,7 +30,7 @@ def setup_colors(reinit=False):
print()
def get_color(category, bold=False, reverse=False, underline=False):
def get_color(category: str, bold: bool = False, reverse: bool = False, underline: bool = False) -> int:
"""
Retrieve a curses color pair with optional attributes.
"""
@@ -40,4 +41,4 @@ def get_color(category, bold=False, reverse=False, underline=False):
color |= curses.A_REVERSE
if underline:
color |= curses.A_UNDERLINE
return color
return color

920
contact/ui/contact_ui.py Normal file
View File

@@ -0,0 +1,920 @@
import curses
import textwrap
import logging
import traceback
from typing import Union
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
from contact.settings import settings_menu
from contact.message_handlers.tx_handler import send_message, send_traceroute
from contact.ui.colors import get_color
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
from contact.utilities.input_handlers import get_list_input
import contact.ui.default_config as config
import contact.ui.dialog
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines
from contact.utilities.singleton import ui_state, interface_state
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win
# Calculate window max dimensions
height, width = stdscr.getmaxyx()
# Define window dimensions and positions
channel_width = 3 * (width // 16)
nodes_width = 5 * (width // 16)
messages_width = width - channel_width - nodes_width
if firstrun:
entry_win = curses.newwin(3, width, 0, 0)
channel_win = curses.newwin(height - 6, channel_width, 3, 0)
messages_win = curses.newwin(height - 6, messages_width, 3, channel_width)
nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
function_win = curses.newwin(3, width, height - 3, 0)
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
# Will be resized to what we need when drawn
messages_pad = curses.newpad(1, 1)
nodes_pad = curses.newpad(1, 1)
channel_pad = curses.newpad(1, 1)
# Set background colors for windows
for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]:
win.bkgd(get_color("background"))
# Set background colors for pads
for pad in [messages_pad, nodes_pad, channel_pad]:
pad.bkgd(get_color("background"))
# Set colors for window frames
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
win.attrset(get_color("window_frame"))
else:
for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]:
win.erase()
entry_win.resize(3, width)
channel_win.resize(height - 6, channel_width)
messages_win.resize(height - 6, messages_width)
messages_win.mvwin(3, channel_width)
nodes_win.resize(height - 6, nodes_width)
nodes_win.mvwin(3, channel_width + messages_width)
function_win.resize(3, width)
function_win.mvwin(height - 3, 0)
packetlog_win.resize(int(height / 3), messages_width)
packetlog_win.mvwin(height - int(height / 3) - 3, channel_width)
# Draw window borders
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
win.box()
win.refresh()
entry_win.keypad(True)
curses.curs_set(1)
try:
draw_function_win()
draw_channel_list()
draw_messages_window(True)
draw_node_list()
except:
# Resize events can come faster than we can re-draw, which can cause a curses error.
# In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later.
pass
def main_ui(stdscr: curses.window) -> None:
global input_text
input_text = ""
stdscr.keypad(True)
get_channels()
handle_resize(stdscr, True)
while True:
draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
# Get user input from entry window
char = entry_win.get_wch()
# draw_debug(f"Keypress: {char}")
if char == curses.KEY_UP:
if ui_state.current_window == 0:
scroll_channels(-1)
elif ui_state.current_window == 1:
scroll_messages(-1)
elif ui_state.current_window == 2:
scroll_nodes(-1)
elif char == curses.KEY_DOWN:
if ui_state.current_window == 0:
scroll_channels(1)
elif ui_state.current_window == 1:
scroll_messages(1)
elif ui_state.current_window == 2:
scroll_nodes(1)
elif char == curses.KEY_HOME:
if ui_state.current_window == 0:
select_channel(0)
elif ui_state.current_window == 1:
ui_state.selected_message = 0
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(0)
elif char == curses.KEY_END:
if ui_state.current_window == 0:
select_channel(len(ui_state.channel_list) - 1)
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0)
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(len(ui_state.node_list) - 1)
elif char == curses.KEY_PPAGE:
if ui_state.current_window == 0:
select_channel(
ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)
) # select_channel will bounds check for us
elif ui_state.current_window == 1:
ui_state.selected_message = max(
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
)
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(
ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)
) # select_node will bounds check for us
elif char == curses.KEY_NPAGE:
if ui_state.current_window == 0:
select_channel(
ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2)
) # select_channel will bounds check for us
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = min(
ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win),
msg_line_count - get_msg_window_lines(messages_win, packetlog_win),
)
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(
ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)
) # select_node will bounds check for us
elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT:
delta = -1 if char == curses.KEY_LEFT else 1
old_window = ui_state.current_window
ui_state.current_window = (ui_state.current_window + delta) % 3
if old_window == 0:
channel_win.attrset(get_color("window_frame"))
channel_win.box()
channel_win.refresh()
refresh_pad(0)
if old_window == 1:
messages_win.attrset(get_color("window_frame"))
messages_win.box()
messages_win.refresh()
refresh_pad(1)
elif old_window == 2:
draw_function_win()
nodes_win.attrset(get_color("window_frame"))
nodes_win.box()
nodes_win.refresh()
refresh_pad(2)
if ui_state.current_window == 0:
channel_win.attrset(get_color("window_frame_selected"))
channel_win.box()
channel_win.attrset(get_color("window_frame"))
channel_win.refresh()
refresh_pad(0)
elif ui_state.current_window == 1:
messages_win.attrset(get_color("window_frame_selected"))
messages_win.box()
messages_win.attrset(get_color("window_frame"))
messages_win.refresh()
refresh_pad(1)
elif ui_state.current_window == 2:
draw_function_win()
nodes_win.attrset(get_color("window_frame_selected"))
nodes_win.box()
nodes_win.attrset(get_color("window_frame"))
nodes_win.refresh()
refresh_pad(2)
# Check for Esc
elif char == chr(27):
break
# Check for Ctrl + t
elif char == chr(20):
send_traceroute()
curses.curs_set(0) # Hide cursor
contact.ui.dialog.dialog(
stdscr,
"Traceroute Sent",
"Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.",
)
curses.curs_set(1) # Show cursor again
handle_resize(stdscr, False)
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
if ui_state.current_window == 2:
node_list = ui_state.node_list
if node_list[ui_state.selected_node] not in ui_state.channel_list:
ui_state.channel_list.append(node_list[ui_state.selected_node])
if node_list[ui_state.selected_node] not in ui_state.all_messages:
ui_state.all_messages[node_list[ui_state.selected_node]] = []
ui_state.selected_channel = ui_state.channel_list.index(node_list[ui_state.selected_node])
if is_chat_archived(ui_state.channel_list[ui_state.selected_channel]):
update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=False)
ui_state.selected_node = 0
ui_state.current_window = 0
draw_node_list()
draw_channel_list()
draw_messages_window(True)
elif len(input_text) > 0:
# Enter key pressed, send user input as message
send_message(input_text, channel=ui_state.selected_channel)
draw_messages_window(True)
# Clear entry window and reset input text
input_text = ""
entry_win.erase()
elif char in (curses.KEY_BACKSPACE, chr(127)):
if input_text:
input_text = input_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(" ") #
entry_win.move(y, x - 1)
entry_win.refresh()
elif char == "`": # ` Launch the settings interface
curses.curs_set(0)
settings_menu(stdscr, interface_state.interface)
curses.curs_set(1)
refresh_node_list()
handle_resize(stdscr, False)
elif char == chr(16):
# Display packet log
if ui_state.display_log is False:
ui_state.display_log = True
draw_messages_window(True)
else:
ui_state.display_log = False
packetlog_win.erase()
draw_messages_window(True)
elif char == curses.KEY_RESIZE:
input_text = ""
handle_resize(stdscr, False)
# ^D
elif char == chr(4):
if ui_state.current_window == 0:
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=True)
# Shift notifications up to account for deleted item
for i in range(len(ui_state.notifications)):
if ui_state.notifications[i] > ui_state.selected_channel:
ui_state.notifications[i] -= 1
del ui_state.channel_list[ui_state.selected_channel]
ui_state.selected_channel = min(ui_state.selected_channel, len(ui_state.channel_list) - 1)
select_channel(ui_state.selected_channel)
draw_channel_list()
draw_messages_window()
if ui_state.current_window == 2:
curses.curs_set(0)
confirmation = get_list_input(
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from nodedb?",
"No",
["Yes", "No"],
)
if confirmation == "Yes":
interface_state.interface.localNode.removeNode(ui_state.node_list[ui_state.selected_node])
# Directly modifying the interface from client code - good? Bad? If it's stupid but it works, it's not supid?
del interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
# Convert to "!hex" representation that interface.nodes uses
hexid = f"!{hex(ui_state.node_list[ui_state.selected_node])[2:]}"
del interface_state.interface.nodes[hexid]
ui_state.node_list.pop(ui_state.selected_node)
draw_messages_window()
draw_node_list()
else:
draw_messages_window()
curses.curs_set(1)
continue
# ^/
elif char == chr(31):
if ui_state.current_window == 2 or ui_state.current_window == 0:
search(ui_state.current_window)
# ^F
elif char == chr(6):
if ui_state.current_window == 2:
selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
curses.curs_set(0)
if "isFavorite" not in selectedNode or selectedNode["isFavorite"] == False:
confirmation = get_list_input(
f"Set {get_name_from_database(ui_state.node_list[ui_state.selected_node])} as Favorite?",
None,
["Yes", "No"],
)
if confirmation == "Yes":
interface_state.interface.localNode.setFavorite(ui_state.node_list[ui_state.selected_node])
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
"isFavorite"
] = True
refresh_node_list()
else:
confirmation = get_list_input(
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Favorites?",
None,
["Yes", "No"],
)
if confirmation == "Yes":
interface_state.interface.localNode.removeFavorite(ui_state.node_list[ui_state.selected_node])
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
"isFavorite"
] = False
refresh_node_list()
handle_resize(stdscr, False)
elif char == chr(7):
if ui_state.current_window == 2:
selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
curses.curs_set(0)
if "isIgnored" not in selectedNode or selectedNode["isIgnored"] == False:
confirmation = get_list_input(
f"Set {get_name_from_database(ui_state.node_list[ui_state.selected_node])} as Ignored?",
"No",
["Yes", "No"],
)
if confirmation == "Yes":
interface_state.interface.localNode.setIgnored(ui_state.node_list[ui_state.selected_node])
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
"isIgnored"
] = True
else:
confirmation = get_list_input(
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Ignored?",
"No",
["Yes", "No"],
)
if confirmation == "Yes":
interface_state.interface.localNode.removeIgnored(ui_state.node_list[ui_state.selected_node])
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
"isIgnored"
] = False
handle_resize(stdscr, False)
else:
# Append typed character to input text
if isinstance(char, str):
input_text += char
else:
input_text += chr(char)
def draw_channel_list() -> None:
channel_pad.erase()
win_width = channel_win.getmaxyx()[1]
channel_pad.resize(len(ui_state.all_messages), channel_win.getmaxyx()[1])
idx = 0
for channel in ui_state.channel_list:
# Convert node number to long name if it's an integer
if isinstance(channel, int):
if is_chat_archived(channel):
continue
channel_name = get_name_from_database(channel, type="long")
if channel_name is None:
continue
channel = channel_name
# Determine whether to add the notification
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
# Truncate the channel name if it's too long to fit in the window
truncated_channel = (
(channel[: win_width - 5] + "-" if len(channel) > win_width - 5 else channel) + notification
).ljust(win_width - 3)
color = get_color("channel_list")
if idx == ui_state.selected_channel:
if ui_state.current_window == 0:
color = get_color("channel_list", reverse=True)
remove_notification(ui_state.selected_channel)
else:
color = get_color("channel_selected")
channel_pad.addstr(idx, 1, truncated_channel, color)
idx += 1
channel_win.attrset(
get_color("window_frame_selected") if ui_state.current_window == 0 else get_color("window_frame")
)
channel_win.box()
channel_win.attrset((get_color("window_frame")))
draw_main_arrows(channel_win, len(ui_state.channel_list), window=0)
channel_win.refresh()
refresh_pad(0)
def draw_messages_window(scroll_to_bottom: bool = False) -> None:
"""Update the messages window based on the selected channel and scroll position."""
messages_pad.erase()
channel = ui_state.channel_list[ui_state.selected_channel]
if channel in ui_state.all_messages:
messages = ui_state.all_messages[channel]
msg_line_count = 0
row = 0
for prefix, message in messages:
full_message = f"{prefix}{message}"
wrapped_lines = textwrap.wrap(full_message, messages_win.getmaxyx()[1] - 2)
msg_line_count += len(wrapped_lines)
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
for line in wrapped_lines:
if prefix.startswith("--"):
color = get_color("timestamps")
elif prefix.startswith(config.sent_message_prefix):
color = get_color("tx_messages")
else:
color = get_color("rx_messages")
messages_pad.addstr(row, 1, line, color)
row += 1
messages_win.attrset(
get_color("window_frame_selected") if ui_state.current_window == 1 else get_color("window_frame")
)
messages_win.box()
messages_win.attrset(get_color("window_frame"))
messages_win.refresh()
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
if scroll_to_bottom:
ui_state.selected_message = max(msg_line_count - visible_lines, 0)
ui_state.start_index[1] = max(msg_line_count - visible_lines, 0)
pass
else:
ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0)
draw_main_arrows(
messages_win,
msg_line_count,
window=1,
log_height=packetlog_win.getmaxyx()[0],
)
messages_win.refresh()
refresh_pad(1)
draw_packetlog_win()
def draw_node_list() -> None:
global nodes_pad
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
# if nodes_pad is None:
# nodes_pad = curses.newpad(1, 1)
nodes_pad = curses.newpad(1, 1)
try:
nodes_pad.erase()
box_width = nodes_win.getmaxyx()[1]
nodes_pad.resize(len(ui_state.node_list) + 1, box_width)
except Exception as e:
logging.error(f"Error Drawing Nodes List: {e}")
logging.error("Traceback: %s", traceback.format_exc())
for i, node_num in enumerate(ui_state.node_list):
node = interface_state.interface.nodesByNum[node_num]
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
node_str = f"{'🔐' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[
: box_width - 2
]
color = "node_list"
if "isFavorite" in node and node["isFavorite"]:
color = "node_favorite"
if "isIgnored" in node and node["isIgnored"]:
color = "node_ignored"
nodes_pad.addstr(
i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2)
)
nodes_win.attrset(
get_color("window_frame_selected") if ui_state.current_window == 2 else get_color("window_frame")
)
nodes_win.box()
nodes_win.attrset(get_color("window_frame"))
draw_main_arrows(nodes_win, len(ui_state.node_list), window=2)
nodes_win.refresh()
refresh_pad(2)
# Restore cursor to input field
entry_win.keypad(True)
curses.curs_set(1)
entry_win.refresh()
def select_channel(idx: int) -> None:
old_selected_channel = ui_state.selected_channel
ui_state.selected_channel = max(0, min(idx, len(ui_state.channel_list) - 1))
draw_messages_window(True)
# For now just re-draw channel list when clearing notifications, we can probably make this more efficient
if ui_state.selected_channel in ui_state.notifications:
remove_notification(ui_state.selected_channel)
draw_channel_list()
return
move_main_highlight(
old_idx=old_selected_channel,
new_idx=ui_state.selected_channel,
options=ui_state.channel_list,
menu_win=channel_win,
menu_pad=channel_pad,
ui_state=ui_state,
)
def scroll_channels(direction: int) -> None:
new_selected_channel = ui_state.selected_channel + direction
if new_selected_channel < 0:
new_selected_channel = len(ui_state.channel_list) - 1
elif new_selected_channel >= len(ui_state.channel_list):
new_selected_channel = 0
select_channel(new_selected_channel)
def scroll_messages(direction: int) -> None:
ui_state.selected_message += direction
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(
0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines(messages_win, packetlog_win))
)
max_index = msg_line_count - 1
visible_height = get_msg_window_lines(messages_win, packetlog_win)
if ui_state.selected_message < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
elif ui_state.selected_message >= ui_state.start_index[ui_state.current_window]: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
# Ensure start_index is within bounds
ui_state.start_index[ui_state.current_window] = max(
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
)
draw_main_arrows(
messages_win,
msg_line_count,
ui_state.current_window,
log_height=packetlog_win.getmaxyx()[0],
)
messages_win.refresh()
refresh_pad(1)
def select_node(idx: int) -> None:
old_selected_node = ui_state.selected_node
ui_state.selected_node = max(0, min(idx, len(ui_state.node_list) - 1))
move_main_highlight(
old_idx=old_selected_node,
new_idx=ui_state.selected_node,
options=ui_state.node_list,
menu_win=nodes_win,
menu_pad=nodes_pad,
ui_state=ui_state,
)
draw_function_win()
def scroll_nodes(direction: int) -> None:
new_selected_node = ui_state.selected_node + direction
if new_selected_node < 0:
new_selected_node = len(ui_state.node_list) - 1
elif new_selected_node >= len(ui_state.node_list):
new_selected_node = 0
select_node(new_selected_node)
def draw_packetlog_win() -> None:
columns = [10, 10, 15, 30]
span = 0
if ui_state.display_log:
packetlog_win.erase()
height, width = packetlog_win.getmaxyx()
for column in columns[:-1]:
span += column
# Add headers
headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}"
packetlog_win.addstr(
1, 1, headers[: width - 2], get_color("log_header", underline=True)
) # Truncate headers if they exceed window width
for i, packet in enumerate(reversed(ui_state.packet_buffer)):
if i >= height - 3: # Skip if exceeds the window height
break
# Format each field
from_id = get_name_from_database(packet["from"], "short").ljust(columns[0])
to_id = (
"BROADCAST".ljust(columns[1])
if str(packet["to"]) == "4294967295"
else get_name_from_database(packet["to"], "short").ljust(columns[1])
)
if "decoded" in packet:
port = packet["decoded"]["portnum"].ljust(columns[2])
payload = (packet["decoded"]["payload"]).ljust(columns[3])
else:
port = "NO KEY".ljust(columns[2])
payload = "NO KEY".ljust(columns[3])
# Combine and truncate if necessary
logString = f"{from_id} {to_id} {port} {payload}"
logString = logString[: width - 3]
# Add to the window
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
packetlog_win.attrset(get_color("window_frame"))
packetlog_win.box()
packetlog_win.refresh()
# Restore cursor to input field
entry_win.keypad(True)
curses.curs_set(1)
entry_win.refresh()
def search(win: int) -> None:
start_idx = ui_state.selected_node
select_func = select_node
if win == 0:
start_idx = ui_state.selected_channel
select_func = select_channel
search_text = ""
entry_win.erase()
while True:
draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input"))
char = entry_win.get_wch()
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif char == "\t":
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
elif char in (curses.KEY_BACKSPACE, chr(127)):
if search_text:
search_text = search_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(" ") #
entry_win.move(y, x - 1)
entry_win.erase()
entry_win.refresh()
elif isinstance(char, str):
search_text += char
search_text_caseless = search_text.casefold()
l = ui_state.node_list if win == 2 else ui_state.channel_list
for i, n in enumerate(l[start_idx:] + l[:start_idx]):
if (
isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "long").casefold()
or isinstance(n, int)
and search_text_caseless in get_name_from_database(n, "short").casefold()
or search_text_caseless in str(n).casefold()
):
select_func((i + start_idx) % len(l))
break
entry_win.erase()
def draw_node_details() -> None:
node = None
try:
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
except KeyError:
return
function_win.erase()
function_win.box()
nodestr = ""
width = function_win.getmaxyx()[1]
node_details_list = [
f"{node['user']['longName']} " if "user" in node and "longName" in node["user"] else "",
f"({node['user']['shortName']})" if "user" in node and "shortName" in node["user"] else "",
f" | {node['user']['hwModel']}" if "user" in node and "hwModel" in node["user"] else "",
f" | {node['user']['role']}" if "user" in node and "role" in node["user"] else "",
]
if ui_state.node_list[ui_state.selected_node] == interface_state.myNodeNum:
node_details_list.extend(
[
(
f" | Bat: {node['deviceMetrics']['batteryLevel']}% ({node['deviceMetrics']['voltage']}v)"
if "deviceMetrics" in node
and "batteryLevel" in node["deviceMetrics"]
and "voltage" in node["deviceMetrics"]
else ""
),
(
f" | Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}"
if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"]
else ""
),
(
f" | ChUtil: {node['deviceMetrics']['channelUtilization']:.2f}%"
if "deviceMetrics" in node and "channelUtilization" in node["deviceMetrics"]
else ""
),
(
f" | AirUtilTX: {node['deviceMetrics']['airUtilTx']:.2f}%"
if "deviceMetrics" in node and "airUtilTx" in node["deviceMetrics"]
else ""
),
]
)
else:
node_details_list.extend(
[
f" | {get_time_ago(node['lastHeard'])}" if ("lastHeard" in node and node["lastHeard"]) else "",
f" | Hops: {node['hopsAway']}" if "hopsAway" in node else "",
f" | SNR: {node['snr']}dB" if ("snr" in node and "hopsAway" in node and node["hopsAway"] == 0) else "",
]
)
for s in node_details_list:
if len(nodestr) + len(s) < width - 2:
nodestr = nodestr + s
draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
def draw_help() -> None:
cmds = [
"↑→↓← = Select",
" ENTER = Send",
" ` = Settings",
" ^P = Packet Log",
" ESC = Quit",
" ^t = Traceroute",
" ^d = Archive Chat",
" ^f = Favorite",
" ^g = Ignore",
]
function_str = ""
for s in cmds:
if len(function_str) + len(s) < function_win.getmaxyx()[1] - 2:
function_str += s
draw_centered_text_field(function_win, function_str, 0, get_color("commands"))
def draw_function_win() -> None:
if ui_state.current_window == 2:
draw_node_details()
else:
draw_help()
def refresh_pad(window: int) -> None:
win_height = channel_win.getmaxyx()[0]
if window == 1:
pad = messages_pad
box = messages_win
lines = get_msg_window_lines(messages_win, packetlog_win)
selected_item = ui_state.selected_message
start_index = ui_state.selected_message
if ui_state.display_log:
packetlog_win.box()
packetlog_win.refresh()
elif window == 2:
pad = nodes_pad
box = nodes_win
lines = box.getmaxyx()[0] - 2
selected_item = ui_state.selected_node
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
else:
pad = channel_pad
box = channel_win
lines = box.getmaxyx()[0] - 2
selected_item = ui_state.selected_channel
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
pad.refresh(
start_index,
0,
box.getbegyx()[0] + 1,
box.getbegyx()[1] + 1,
box.getbegyx()[0] + lines,
box.getbegyx()[1] + box.getmaxyx()[1] - 3,
)
def add_notification(channel_number: int) -> None:
if channel_number not in ui_state.notifications:
ui_state.notifications.append(channel_number)
def remove_notification(channel_number: int) -> None:
if channel_number in ui_state.notifications:
ui_state.notifications.remove(channel_number)
def draw_text_field(win: curses.window, text: str, color: int) -> None:
win.border()
win.addstr(1, 1, text, color)
def draw_centered_text_field(win: curses.window, text: str, y_offset: int, color: int) -> None:
height, width = win.getmaxyx()
x = (width - len(text)) // 2
y = (height // 2) + y_offset
win.addstr(y, x, text, color)
win.refresh()
def draw_debug(value: Union[str, int]) -> None:
function_win.addstr(1, 1, f"debug: {value} ")
function_win.refresh()

535
contact/ui/control_ui.py Normal file
View File

@@ -0,0 +1,535 @@
import base64
import curses
import logging
import os
import sys
from typing import List
from contact.utilities.save_to_radio import save_changes
from contact.utilities.config_io import config_export, config_import
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
from contact.utilities.input_handlers import (
get_repeated_input,
get_text_input,
get_fixed32_input,
get_list_input,
get_admin_key_input,
)
from contact.ui.colors import get_color
from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.user_config import json_editor
from contact.ui.ui_state import MenuState
menu_state = MenuState()
# Constants
width = 80
save_option = "Save Changes"
max_help_lines = 0
help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
# Paths
locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
translation_file = os.path.join(parent_dir, "localisations", "en.ini")
config_folder = os.path.join(locals_dir, "node-configs")
# Load translations
field_mapping, help_text = parse_ini_file(translation_file)
def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.window or pad types
min_help_window_height = 6
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
# Determine the available height for the menu
max_menu_height = curses.LINES
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
start_x = (curses.COLS - width) // 2
# Calculate remaining space for help window
global max_help_lines
remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding
max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help
menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, width - 8)
menu_pad.bkgd(get_color("background"))
header = " > ".join(word.title() for word in menu_state.menu_path)
if len(header) > width - 4:
header = header[: width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
transformed_path = transform_menu_path(menu_state.menu_path)
for idx, option in enumerate(menu_state.current_menu):
field_info = menu_state.current_menu[option]
current_value = field_info[1] if isinstance(field_info, tuple) else ""
full_key = ".".join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
display_option = f"{display_name}"[: width // 2 - 2]
display_value = f"{current_value}"[: width // 2 - 4]
try:
color = get_color(
"settings_sensitive" if option in sensitive_settings else "settings_default",
reverse=(idx == menu_state.selected_index),
)
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
except curses.error:
pass
if menu_state.show_save_option:
save_position = menu_height - 2
menu_win.addstr(
save_position,
(width - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
# Draw help window with dynamically updated max_help_lines
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, menu_state)
menu_win.refresh()
menu_pad.refresh(
menu_state.start_index[-1],
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
return menu_win, menu_pad
def draw_help_window(
menu_start_y: int,
menu_start_x: int,
menu_height: int,
max_help_lines: int,
transformed_path: List[str],
menu_state: MenuState,
) -> None:
global help_win
if "help_win" not in globals():
help_win = None # Initialize if it does not exist
selected_option = (
list(menu_state.current_menu.keys())[menu_state.selected_index] if menu_state.current_menu else None
)
help_y = menu_start_y + menu_height
help_win = update_help_window(
help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x
)
def settings_menu(stdscr: object, interface: object) -> None:
curses.update_lines_cols()
menu = generate_menu_from_protobuf(interface)
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
modified_settings = {}
need_redraw = True
menu_state.show_save_option = False
while True:
if need_redraw:
options = list(menu_state.current_menu.keys())
menu_state.show_save_option = (
(
len(menu_state.menu_path) > 2
and ("Radio Settings" in menu_state.menu_path or "Module Settings" in menu_state.menu_path)
)
or (len(menu_state.menu_path) == 2 and "User Settings" in menu_state.menu_path)
or (len(menu_state.menu_path) == 3 and "Channels" in menu_state.menu_path)
)
# Display the menu
menu_win, menu_pad = display_menu(menu_state)
need_redraw = False
# Capture user input
key = menu_win.getch()
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
# max_help_lines = 4
if key == curses.KEY_UP:
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
move_highlight(
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
elif key == curses.KEY_DOWN:
old_selected_index = menu_state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
move_highlight(
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
elif key == curses.KEY_RESIZE:
need_redraw = True
curses.update_lines_cols()
menu_win.erase()
help_win.erase()
menu_win.refresh()
help_win.refresh()
elif key == ord("\t") and menu_state.show_save_option:
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index
move_highlight(
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
elif key == curses.KEY_RIGHT or key == ord("\n"):
need_redraw = True
menu_state.start_index.append(0)
menu_win.erase()
help_win.erase()
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
menu_win.refresh()
help_win.refresh()
if menu_state.show_save_option and menu_state.selected_index == len(options):
save_changes(interface, modified_settings, menu_state)
modified_settings.clear()
logging.info("Changes Saved")
if len(menu_state.menu_path) > 1:
menu_state.menu_path.pop()
menu_state.current_menu = menu["Main Menu"]
for step in menu_state.menu_path[1:]:
menu_state.current_menu = menu_state.current_menu.get(step, {})
menu_state.selected_index = 0
continue
selected_option = options[menu_state.selected_index]
if selected_option == "Exit":
break
elif selected_option == "Export Config File":
filename = get_text_input("Enter a filename for the config file")
if not filename:
logging.info("Export aborted: No filename provided.")
menu_state.start_index.pop()
continue # Go back to the menu
if not filename.lower().endswith(".yaml"):
filename += ".yaml"
try:
config_text = config_export(interface)
yaml_file_path = os.path.join(config_folder, filename)
if os.path.exists(yaml_file_path):
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
if overwrite == "No":
logging.info("Export cancelled: User chose not to overwrite.")
menu_state.start_index.pop()
continue # Return to menu
os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True)
with open(yaml_file_path, "w", encoding="utf-8") as file:
file.write(config_text)
logging.info(f"Config file saved to {yaml_file_path}")
dialog(stdscr, "Config File Saved:", yaml_file_path)
menu_state.start_index.pop()
continue
except PermissionError:
logging.error(f"Permission denied: Unable to write to {yaml_file_path}")
except OSError as e:
logging.error(f"OS error while saving config: {e}")
except Exception as e:
logging.error(f"Unexpected error: {e}")
menu_state.start_index.pop()
continue
elif selected_option == "Load Config File":
# Check if folder exists and is not empty
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
dialog(stdscr, "", " No config files found. Export a config first.")
continue # Return to menu
file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))]
# Ensure file_list is not empty before proceeding
if not file_list:
dialog(stdscr, "", " No config files found. Export a config first.")
continue
filename = get_list_input("Choose a config file", None, file_list)
if filename:
file_path = os.path.join(config_folder, filename)
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
if overwrite == "Yes":
config_import(interface, file_path)
menu_state.start_index.pop()
continue
elif selected_option == "Config URL":
current_value = interface.localNode.getURL()
new_value = get_text_input(f"Config URL is currently: {current_value}")
if new_value is not None:
current_value = new_value
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
if overwrite == "Yes":
interface.localNode.setURL(new_value)
logging.info(f"New Config URL sent to node")
menu_state.start_index.pop()
continue
elif selected_option == "Reboot":
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
menu_state.start_index.pop()
continue
elif selected_option == "Reset Node DB":
confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
menu_state.start_index.pop()
continue
elif selected_option == "Shutdown":
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu")
menu_state.start_index.pop()
continue
elif selected_option == "Factory Reset":
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
menu_state.start_index.pop()
continue
elif selected_option == "App Settings":
menu_win.clear()
menu_win.refresh()
menu_state.menu_path.append("App Settings")
menu_state.menu_index.append(menu_state.selected_index)
json_editor(stdscr, menu_state) # Open the App Settings menu
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
menu_state.start_index.pop()
menu_state.selected_index = 4
continue
# need_redraw = True
field_info = menu_state.current_menu.get(selected_option)
if isinstance(field_info, tuple):
field, current_value = field_info
# Transform the menu path to get the full key
transformed_path = transform_menu_path(menu_state.menu_path)
full_key = ".".join(transformed_path + [selected_option])
# Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option)
if selected_option in ["longName", "shortName", "isLicensed"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
elif selected_option == "isLicensed":
new_value = get_list_input(
f"{human_readable_name} is currently: {current_value}",
str(current_value),
["True", "False"],
)
new_value = new_value == "True"
menu_state.current_menu[selected_option] = (field, new_value)
for option, (field, value) in menu_state.current_menu.items():
modified_settings[option] = value
menu_state.start_index.pop()
elif selected_option in ["latitude", "longitude", "altitude"]:
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
for option in ["latitude", "longitude", "altitude"]:
if option in menu_state.current_menu:
modified_settings[option] = menu_state.current_menu[option][1]
menu_state.start_index.pop()
elif selected_option == "admin_key":
new_values = get_admin_key_input(current_value)
new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values]
menu_state.start_index.pop()
elif field.type == 8: # Handle boolean type
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
if new_value == "Not Set":
pass # Leave it as-is
else:
new_value = new_value == "True" or new_value is True
menu_state.start_index.pop()
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else new_value.split(", ")
menu_state.start_index.pop()
elif field.enum_type: # Enum field
enum_options = {v.name: v.number for v in field.enum_type.values}
new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys()))
new_value = enum_options.get(new_value_name, current_value)
menu_state.start_index.pop()
elif field.type == 7: # Field type 7 corresponds to FIXED32
new_value = get_fixed32_input(current_value)
menu_state.start_index.pop()
elif field.type == 13: # Field type 13 corresponds to UINT32
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else int(new_value)
menu_state.start_index.pop()
elif field.type == 2: # Field type 13 corresponds to INT64
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else float(new_value)
menu_state.start_index.pop()
else: # Handle other field types
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value
menu_state.start_index.pop()
for key in menu_state.menu_path[3:]: # Skip "Main Menu"
modified_settings = modified_settings.setdefault(key, {})
# Add the new value to the appropriate level
modified_settings[selected_option] = new_value
# Convert enum string to int
if field and field.enum_type:
enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
menu_state.current_menu[selected_option] = (field, new_value)
else:
menu_state.current_menu = menu_state.current_menu[selected_option]
menu_state.menu_path.append(selected_option)
menu_state.menu_index.append(menu_state.selected_index)
menu_state.selected_index = 0
elif key == curses.KEY_LEFT:
need_redraw = True
menu_win.erase()
help_win.erase()
# max_help_lines = 4
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
menu_win.refresh()
help_win.refresh()
if len(menu_state.menu_path) < 2:
modified_settings.clear()
# Navigate back to the previous menu
if len(menu_state.menu_path) > 1:
menu_state.menu_path.pop()
menu_state.current_menu = menu["Main Menu"]
for step in menu_state.menu_path[1:]:
menu_state.current_menu = menu_state.current_menu.get(step, {})
menu_state.selected_index = menu_state.menu_index.pop()
menu_state.start_index.pop()
elif key == 27: # Escape key
menu_win.erase()
menu_win.refresh()
break
def set_region(interface: object) -> None:
node = interface.getNode("^local")
device_config = node.localConfig
lora_descriptor = device_config.lora.DESCRIPTOR
# Get the enum mapping of region names to their numerical values
region_enum = lora_descriptor.fields_by_name["region"].enum_type
region_name_to_number = {v.name: v.number for v in region_enum.values}
regions = list(region_name_to_number.keys())
new_region_name = get_list_input("Select your region:", "UNSET", regions)
# Convert region name to corresponding enum number
new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found
node.localConfig.lora.region = new_region_number
node.writeConfig("lora")

View File

@@ -1,18 +1,28 @@
import logging
import json
import logging
import os
from typing import Dict
def format_json_single_line_arrays(data, indent=4):
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
# Paths
json_file_path = os.path.join(parent_dir, "config.json")
log_file_path = os.path.join(parent_dir, "client.log")
db_file_path = os.path.join(parent_dir, "client.db")
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
"""
Formats JSON with arrays on a single line while keeping other elements properly indented.
"""
def format_value(value, current_indent):
def format_value(value: object, current_indent: int) -> str:
if isinstance(value, dict):
items = []
for key, val in value.items():
items.append(
f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}'
)
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)}]"
@@ -21,8 +31,9 @@ def format_json_single_line_arrays(data, indent=4):
return format_value(data, indent)
# Recursive function to check and update nested dictionaries
def update_dict(default, actual):
def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool:
updated = False
for key, value in default.items():
if key not in actual:
@@ -33,10 +44,8 @@ def update_dict(default, actual):
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")
def initialize_config() -> Dict[str, object]:
COLOR_CONFIG_DARK = {
"default": ["white", "black"],
"background": [" ", "black"],
@@ -57,9 +66,12 @@ def initialize_config():
"settings_default": ["white", "black"],
"settings_sensitive": ["red", "black"],
"settings_save": ["green", "black"],
"settings_breadcrumbs": ["white", "black"]
"settings_breadcrumbs": ["white", "black"],
"settings_warning": ["red", "black"],
"settings_note": ["green", "black"],
"node_favorite": ["green", "black"],
"node_ignored": ["red", "black"],
}
COLOR_CONFIG_LIGHT = {
"default": ["black", "white"],
"background": [" ", "white"],
@@ -80,7 +92,11 @@ def initialize_config():
"settings_default": ["black", "white"],
"settings_sensitive": ["red", "white"],
"settings_save": ["green", "white"],
"settings_breadcrumbs": ["black", "white"]
"settings_breadcrumbs": ["black", "white"],
"settings_warning": ["red", "white"],
"settings_note": ["green", "white"],
"node_favorite": ["green", "white"],
"node_ignored": ["red", "white"],
}
COLOR_CONFIG_GREEN = {
"default": ["green", "black"],
@@ -102,12 +118,17 @@ def initialize_config():
"settings_default": ["green", "black"],
"settings_sensitive": ["green", "black"],
"settings_save": ["green", "black"],
"settings_breadcrumbs": ["green", "black"]
"settings_breadcrumbs": ["green", "black"],
"settings_save": ["green", "black"],
"settings_breadcrumbs": ["green", "black"],
"settings_warning": ["green", "black"],
"settings_note": ["green", "black"],
"node_favorite": ["cyan", "white"],
"node_ignored": ["red", "white"],
}
default_config_variables = {
"db_file_path": os.path.join(app_directory, "client.db"),
"log_file_path": os.path.join(app_directory, "client.log"),
"db_file_path": db_file_path,
"log_file_path": log_file_path,
"message_prefix": ">>",
"sent_message_prefix": ">> Sent",
"notification_symbol": "*",
@@ -119,7 +140,7 @@ def initialize_config():
"theme": "dark",
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN,
}
if not os.path.exists(json_file_path):
@@ -136,16 +157,17 @@ def initialize_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.")
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):
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
# 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
@@ -176,9 +198,9 @@ 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"
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}")
@@ -190,4 +212,4 @@ if __name__ == "__main__":
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}")
print(f"Color Config: {COLOR_CONFIG}")

View File

@@ -1,7 +1,8 @@
import curses
from ui.colors import get_color
from contact.ui.colors import get_color
def dialog(stdscr, title, message):
def dialog(stdscr: curses.window, title: str, message: str) -> None:
height, width = stdscr.getmaxyx()
# Calculate dialog dimensions
@@ -37,7 +38,7 @@ def dialog(stdscr, title, message):
while True:
char = win.getch()
# Close dialog with enter, space, or esc
if char in(curses.KEY_ENTER, 10, 13, 32, 27):
if char in (curses.KEY_ENTER, 10, 13, 32, 27):
win.erase()
win.refresh()
return

View File

@@ -1,20 +1,45 @@
from collections import OrderedDict
from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2
import logging
import base64
import logging
import os
from collections import OrderedDict
from typing import Any, Union, Dict
from google.protobuf.message import Message
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
def extract_fields(message_instance, current_config=None):
locals_dir = os.path.dirname(os.path.abspath(__file__))
translation_file = os.path.join(locals_dir, "localisations", "en.ini")
def encode_if_bytes(value: Any) -> str:
"""Encode byte values to base64 string."""
if isinstance(value, bytes):
return base64.b64encode(value).decode("utf-8")
return value
def extract_fields(
message_instance: Message, current_config: Union[Message, Dict[str, Any], None] = None
) -> Dict[str, Any]:
if isinstance(current_config, dict): # Handle dictionaries
return {key: (None, current_config.get(key, "Not Set")) for key in current_config}
return {key: (None, encode_if_bytes(current_config.get(key, "Not Set"))) for key in current_config}
if not hasattr(message_instance, "DESCRIPTOR"):
return {}
menu = {}
fields = message_instance.DESCRIPTOR.fields
for field in fields:
if field.name in {"sessionkey", "channel_num", "id", "ignore_incoming"}: # Skip certain fields
skip_fields = [
"sessionkey",
"ChannelSettings.channel_num",
"ChannelSettings.id",
"LoRaConfig.ignore_incoming",
"DeviceUIConfig.version",
]
if any(skip_field in field.full_name for skip_field in skip_fields):
continue
if field.message_type: # Nested message
@@ -34,25 +59,26 @@ def extract_fields(message_instance, current_config=None):
menu[field.name] = (field, current_value) # Non-integer values
else: # Handle other field types
current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set"
menu[field.name] = (field, current_value)
menu[field.name] = (field, encode_if_bytes(current_value))
return menu
def generate_menu_from_protobuf(interface):
# Function to generate the menu structure from protobuf messages
def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
"""
Builds the full settings menu structure from the protobuf definitions.
"""
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"))
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
}
else:
logging.info("User settings not found in Node Info")
@@ -69,8 +95,6 @@ def generate_menu_from_protobuf(interface):
current_channel = interface.localNode.getChannelByChannelIndex(i)
if current_channel:
channel_config = extract_fields(channel, current_channel.settings)
# Convert 'psk' field to Base64
channel_config["psk"] = (channel_config["psk"][0], base64.b64encode(channel_config["psk"][1]).decode('utf-8'))
menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config
# Add Radio Settings
@@ -82,13 +106,10 @@ def generate_menu_from_protobuf(interface):
position_data = {
"latitude": (None, current_node_info["position"].get("latitude", 0.0)),
"longitude": (None, current_node_info["position"].get("longitude", 0.0)),
"altitude": (None, current_node_info["position"].get("altitude", 0))
"altitude": (None, current_node_info["position"].get("altitude", 0)),
}
# Get existing position menu items
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
# Create an ordered position menu with Lat/Lon/Alt inserted in the middle
ordered_position_menu = OrderedDict()
for key, value in existing_position_menu.items():
@@ -98,10 +119,8 @@ def generate_menu_from_protobuf(interface):
else:
ordered_position_menu[key] = value
# Update the menu with the new order
menu_structure["Main Menu"]["Radio Settings"]["position"] = ordered_position_menu
# Add Module Settings
module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None
@@ -110,15 +129,18 @@ def generate_menu_from_protobuf(interface):
# Add App Settings
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
# Add additional settings options
menu_structure["Main Menu"]["Export Config"] = None
menu_structure["Main Menu"]["Load Config"] = None
menu_structure["Main Menu"]["Reboot"] = None
menu_structure["Main Menu"]["Reset Node DB"] = None
menu_structure["Main Menu"]["Shutdown"] = None
menu_structure["Main Menu"]["Factory Reset"] = None
# Additional settings options
menu_structure["Main Menu"].update(
{
"Export Config File": None,
"Load Config File": None,
"Config URL": None,
"Reboot": None,
"Reset Node DB": None,
"Shutdown": None,
"Factory Reset": None,
"Exit": None,
}
)
# Add Exit option
menu_structure["Main Menu"]["Exit"] = None
return menu_structure
return menu_structure

417
contact/ui/nav_utils.py Normal file
View File

@@ -0,0 +1,417 @@
import curses
import re
from contact.ui.colors import get_color
from contact.utilities.control_utils import transform_menu_path
from typing import Any, Optional, List, Dict
from contact.utilities.singleton import interface_state, ui_state
def get_node_color(node_index: int, reverse: bool = False):
node_num = ui_state.node_list[node_index]
node = interface_state.interface.nodesByNum.get(node_num, {})
if node.get("isFavorite"):
return get_color("node_favorite", reverse=reverse)
elif node.get("isIgnored"):
return get_color("node_ignored", reverse=reverse)
return get_color("settings_default", reverse=reverse)
# Aliases
Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment]
width = 80
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
def move_highlight(
old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any
) -> None:
show_save_option = None
start_index = [0]
help_text = None
max_help_lines = 0
help_win = None
if "help_win" in kwargs:
help_win = kwargs["help_win"]
if "menu_state" in kwargs:
new_idx = kwargs["menu_state"].selected_index
show_save_option = kwargs["menu_state"].show_save_option
start_index = kwargs["menu_state"].start_index
transformed_path = transform_menu_path(kwargs["menu_state"].menu_path)
else:
new_idx = kwargs["selected_index"]
transformed_path = []
if "help_text" in kwargs:
help_text = kwargs["help_text"]
if "max_help_lines" in kwargs:
max_help_lines = kwargs["max_help_lines"]
if old_idx == new_idx: # No-op
return
max_index = len(options) + (1 if show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)
# Adjust menu_state.start_index only when moving out of visible range
if new_idx == max_index and show_save_option:
pass
elif new_idx < start_index[-1]: # Moving above the visible area
start_index[-1] = new_idx
elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area
start_index[-1] = new_idx - visible_height
# Ensure menu_state.start_index is within bounds
start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1))
# Clear old selection
if show_save_option and old_idx == max_index:
menu_win.chgat(
menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")
)
else:
menu_pad.chgat(
old_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive")
if options[old_idx] in sensitive_settings
else get_color("settings_default")
),
)
# Highlight new selection
if show_save_option and new_idx == max_index:
menu_win.chgat(
menu_win.getmaxyx()[0] - 2,
(width - len(save_option)) // 2,
len(save_option),
get_color("settings_save", reverse=True),
)
else:
menu_pad.chgat(
new_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive", reverse=True)
if options[new_idx] in sensitive_settings
else get_color("settings_default", reverse=True)
),
)
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(
start_index[-1],
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
# Update help window only if help_text is populated
selected_option = options[new_idx] if new_idx < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
if help_win:
help_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
width,
help_y,
menu_win.getbegyx()[1],
)
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)
def draw_arrows(
win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool
) -> None:
mi = max_index - (2 if show_save_option else 0)
if visible_height < mi:
if start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1):
win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
def update_help_window(
help_win: object, # curses window or None
help_text: Dict[str, str],
transformed_path: List[str],
selected_option: Optional[str],
max_help_lines: int,
width: int,
help_y: int,
help_x: int,
) -> object: # returns a curses window
"""Handles rendering the help window consistently."""
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines)
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
# Ensure help window does not exceed screen size
if help_y + help_height > curses.LINES:
help_y = curses.LINES - help_height
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, width, help_y, help_x)
else:
help_win.erase()
help_win.refresh()
help_win.resize(help_height, width)
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
help_win.border()
for idx, line_segments in enumerate(wrapped_help):
x_pos = 2 # Start after border
for text, color, bold, underline in line_segments:
try:
attr = get_color(color, bold=bold, underline=underline)
help_win.addstr(1 + idx, x_pos, text, attr)
x_pos += len(text)
except curses.error:
pass # Prevent crashes
help_win.refresh()
return help_win
def get_wrapped_help_text(
help_text: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], width: int, max_lines: int
) -> List[WrappedLine]:
"""Fetches and formats help text for display, ensuring it fits within the allowed lines."""
full_help_key = ".".join(transformed_path + [selected_option]) if selected_option else None
help_content = help_text.get(full_help_key, "No help available.")
wrap_width = max(width - 6, 10) # Ensure a valid wrapping width
# Color replacements
color_mappings = {
r"\[warning\](.*?)\[/warning\]": ("settings_warning", True, False), # Red for warnings
r"\[note\](.*?)\[/note\]": ("settings_note", True, False), # Green for notes
r"\[underline\](.*?)\[/underline\]": ("settings_default", False, True), # Underline
r"\\033\[31m(.*?)\\033\[0m": ("settings_warning", True, False), # Red text
r"\\033\[32m(.*?)\\033\[0m": ("settings_note", True, False), # Green text
r"\\033\[4m(.*?)\\033\[0m": ("settings_default", False, True), # Underline
}
def extract_ansi_segments(text: str) -> List[Segment]:
"""Extracts and replaces ANSI color codes, ensuring spaces are preserved."""
matches = []
last_pos = 0
pattern_matches = []
# Find all matches and store their positions
for pattern, (color, bold, underline) in color_mappings.items():
for match in re.finditer(pattern, text):
pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline))
# Sort matches by start position to process sequentially
pattern_matches.sort(key=lambda x: x[0])
for start, end, content, color, bold, underline in pattern_matches:
# Preserve non-matching text including spaces
if last_pos < start:
segment = text[last_pos:start]
matches.append((segment, "settings_default", False, False))
# Append the colored segment
matches.append((content, color, bold, underline))
last_pos = end
# Preserve any trailing text
if last_pos < len(text):
matches.append((text[last_pos:], "settings_default", False, False))
return matches
def wrap_ansi_text(segments: List[Segment], wrap_width: int) -> List[WrappedLine]:
"""Wraps text while preserving ANSI formatting and spaces."""
wrapped_lines = []
line_buffer = []
line_length = 0
for text, color, bold, underline in segments:
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
for word in words:
word_length = len(word)
if line_length + word_length > wrap_width and word.strip():
# If the word (ignoring spaces) exceeds width, wrap the line
wrapped_lines.append(line_buffer)
line_buffer = []
line_length = 0
line_buffer.append((word, color, bold, underline))
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer)
return wrapped_lines
raw_lines = help_content.split("\\n") # Preserve new lines
wrapped_help = []
for raw_line in raw_lines:
color_segments = extract_ansi_segments(raw_line)
wrapped_segments = wrap_ansi_text(color_segments, wrap_width)
wrapped_help.extend(wrapped_segments)
pass
# Trim and add ellipsis if needed
if len(wrapped_help) > max_lines:
wrapped_help = wrapped_help[:max_lines]
wrapped_help[-1].append(("...", "settings_default", False, False))
return wrapped_help
def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words."""
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
wrapped_lines = []
line_buffer = ""
line_length = 0
margin = 2 # Left and right margin
wrap_width -= margin
for word in words:
word_length = len(word)
if word_length > wrap_width: # Break long words
if line_buffer:
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
for i in range(0, word_length, wrap_width):
wrapped_lines.append(word[i : i + wrap_width])
continue
if line_length + word_length > wrap_width and word.strip():
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
line_buffer += word
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer)
return wrapped_lines
def move_main_highlight(
old_idx: int, new_idx, options: List[str], menu_win: curses.window, menu_pad: curses.window, ui_state: object
) -> None:
if old_idx == new_idx: # No-op
return
max_index = len(options) - 1
visible_height = menu_win.getmaxyx()[0] - 2
if new_idx < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = new_idx
elif new_idx >= ui_state.start_index[ui_state.current_window] + visible_height: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = new_idx - visible_height + 1
# Ensure start_index is within bounds
ui_state.start_index[ui_state.current_window] = max(
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
)
highlight_line(menu_win, menu_pad, old_idx, new_idx, visible_height)
if ui_state.current_window == 0: # hack to fix max_index
max_index += 1
draw_main_arrows(menu_win, max_index, window=ui_state.current_window)
menu_win.refresh()
def highlight_line(
menu_win: curses.window, menu_pad: curses.window, old_idx: int, new_idx: int, visible_height: int
) -> None:
if ui_state.current_window == 0:
color_old = (
get_color("channel_selected") if old_idx == ui_state.selected_channel else get_color("channel_list")
)
color_new = get_color("channel_list", reverse=True) if True else get_color("channel_list", reverse=True)
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, color_old)
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
elif ui_state.current_window == 2:
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(
ui_state.start_index[ui_state.current_window],
0,
menu_win.getbegyx()[0] + 1,
menu_win.getbegyx()[1] + 1,
menu_win.getbegyx()[0] + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 3,
)
def draw_main_arrows(win: object, max_index: int, window: int, **kwargs) -> None:
height, width = win.getmaxyx()
usable_height = height - 2
usable_width = width - 2
if window == 1 and ui_state.display_log:
if log_height := kwargs.get("log_height"):
usable_height -= log_height - 1
if usable_height < max_index:
if ui_state.start_index[window] > 0:
win.addstr(1, usable_width, "", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
if max_index - ui_state.start_index[window] - 1 >= usable_height:
win.addstr(usable_height, usable_width, "", get_color("settings_default"))
else:
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
def get_msg_window_lines(messages_win, packetlog_win) -> None:
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0
return messages_win.getmaxyx()[0] - 2 - packetlog_height

29
contact/ui/splash.py Normal file
View File

@@ -0,0 +1,29 @@
import curses
from contact.ui.colors import get_color
def draw_splash(stdscr: object) -> None:
"""Draw the splash screen with a logo and connecting message."""
curses.curs_set(0)
stdscr.clear()
stdscr.bkgd(get_color("background"))
height, width = stdscr.getmaxyx()
message_1 = "/ Λ"
message_2 = "/ / \\"
message_3 = "P W R D"
message_4 = "connecting..."
start_x = width // 2 - len(message_1) // 2
start_x2 = width // 2 - len(message_4) // 2
start_y = height // 2 - 1
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
stdscr.attrset(get_color("window_frame"))
stdscr.box()
stdscr.refresh()
curses.napms(500)

42
contact/ui/ui_state.py Normal file
View File

@@ -0,0 +1,42 @@
from typing import Any, Union, List, Dict
from dataclasses import dataclass, field
@dataclass
class MenuState:
menu_index: List[int] = field(default_factory=list)
start_index: List[int] = field(default_factory=lambda: [0])
selected_index: int = 0
current_menu: Union[Dict[str, Any], List[Any], str, int] = field(default_factory=dict)
menu_path: List[str] = field(default_factory=list)
show_save_option: bool = False
@dataclass
class ChatUIState:
display_log: bool = False
channel_list: List[str] = field(default_factory=list)
all_messages: Dict[str, List[str]] = field(default_factory=dict)
notifications: List[str] = field(default_factory=list)
packet_buffer: List[str] = field(default_factory=list)
node_list: List[str] = field(default_factory=list)
selected_channel: int = 0
selected_message: int = 0
selected_node: int = 0
current_window: int = 0
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
@dataclass
class InterfaceState:
interface: Any = None
myNodeNum: int = 0
@dataclass
class AppState:
lock: Any = None

343
contact/ui/user_config.py Normal file
View File

@@ -0,0 +1,343 @@
import os
import json
import curses
from typing import Any, List, Dict
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
from contact.ui.nav_utils import move_highlight, draw_arrows
from contact.utilities.input_handlers import get_list_input
width = 80
max_help_lines = 6
save_option = "Save Changes"
def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
"""
Allows the user to select a foreground and background color for a key.
"""
color_list = [" "] + list(COLOR_MAP.keys())
fg_color = get_list_input(f"Select Foreground Color for {key}", current_value[0], color_list)
bg_color = get_list_input(f"Select Background Color for {key}", current_value[1], color_list)
return [fg_color, bg_color]
def edit_value(key: str, current_value: str) -> str:
height = 10
input_width = width - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create a centered window
edit_win = curses.newwin(height, width, start_y, start_x)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
# Display instructions
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = width - 4 # Account for border and padding
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)]
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
edit_win.refresh()
# Handle theme selection dynamically
if key == "theme":
# Load theme names dynamically from the JSON
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
return get_list_input("Select Theme", current_value, theme_options)
elif key == "node_sort":
sort_options = ["lastHeard", "name", "hops"]
return get_list_input("Sort By", current_value, sort_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1)
scroll_offset = 0 # Determines which part of the text is visible
user_input = ""
input_position = (7, 13) # Tuple for row and column
row, col = input_position # Unpack tuple
while True:
visible_text = user_input[scroll_offset : scroll_offset + input_width] # Only show what fits
edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
edit_win.refresh()
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
key = edit_win.get_wch()
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
curses.curs_set(0)
return current_value # Exit without returning a value
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
if user_input: # Only process if there's something to delete
user_input = user_input[:-1]
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
scroll_offset -= 1 # Move back if text is shorter than scrolled area
else:
if isinstance(key, str):
user_input += key
else:
user_input += chr(key)
if len(user_input) > input_width: # Scroll if input exceeds visible area
scroll_offset += 1
curses.curs_set(0)
return user_input if user_input else current_value
def display_menu(menu_state: Any) -> tuple[Any, Any, List[str]]:
"""
Render the configuration menu with a Save button directly added to the window.
"""
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
# Determine menu items based on the type of current_menu
if isinstance(menu_state.current_menu, dict):
options = list(menu_state.current_menu.keys())
elif isinstance(menu_state.current_menu, list):
options = [f"[{i}]" for i in range(len(menu_state.current_menu))]
else:
options = [] # Fallback in case of unexpected data types
# Calculate dynamic dimensions for the menu
max_menu_height = curses.LINES
menu_height = min(max_menu_height, num_items + 5)
num_items = len(options)
start_y = (curses.LINES - menu_height) // 2
start_x = (curses.COLS - width) // 2
# Create the window
menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
# Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Display the menu path
header = " > ".join(menu_state.menu_path)
if len(header) > width - 4:
header = header[: width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options
for idx, key in enumerate(options):
value = (
menu_state.current_menu[key]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))]
)
display_key = f"{key}"[: width // 2 - 2]
display_value = f"{value}"[: width // 2 - 8]
color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
# Add Save button to the main window
if menu_state.show_save_option:
save_position = menu_height - 2
menu_win.addstr(
save_position,
(width - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
menu_win.refresh()
menu_pad.refresh(
menu_state.start_index[-1],
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
return menu_win, menu_pad, options
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
menu_state.selected_index = 0 # Track the selected option
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
file_path = os.path.join(parent_dir, "config.json")
menu_state.show_save_option = True # Always show the Save button
menu_state.help_win = None
menu_state.help_text = {}
# 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", encoding="utf-8") as f:
original_data = json.load(f)
data = original_data # Reference to the original data
menu_state.current_menu = data # Track the current level of the menu
# Render the menu
menu_win, menu_pad, options = display_menu(menu_state)
need_redraw = True
while True:
if need_redraw:
menu_win, menu_pad, options = display_menu(menu_state)
menu_win.refresh()
need_redraw = False
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
key = menu_win.getch()
if key == curses.KEY_UP:
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
menu_state.help_win = move_highlight(
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
elif key == curses.KEY_DOWN:
old_selected_index = menu_state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
menu_state.help_win = move_highlight(
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
elif key == ord("\t") and menu_state.show_save_option:
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index
menu_state.help_win = move_highlight(
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
need_redraw = True
menu_win.erase()
menu_win.refresh()
if menu_state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[menu_state.selected_index]
menu_state.menu_path.append(str(selected_key))
menu_state.start_index.append(0)
menu_state.menu_index.append(menu_state.selected_index)
# Handle nested data
if isinstance(menu_state.current_menu, dict):
if selected_key in menu_state.current_menu:
selected_data = menu_state.current_menu[selected_key]
else:
continue # Skip invalid key
elif isinstance(menu_state.current_menu, list):
selected_data = menu_state.current_menu[int(selected_key.strip("[]"))]
if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair
new_value = edit_color_pair(selected_key, selected_data)
menu_state.menu_path.pop()
menu_state.start_index.pop()
menu_state.menu_index.pop()
menu_state.current_menu[selected_key] = new_value
elif isinstance(selected_data, (dict, list)):
# Navigate into nested data
menu_state.current_menu = selected_data
menu_state.selected_index = 0 # Reset the selected index
else:
# General value editing
new_value = edit_value(selected_key, selected_data)
menu_state.menu_path.pop()
menu_state.menu_index.pop()
menu_state.start_index.pop()
menu_state.current_menu[selected_key] = new_value
need_redraw = True
else:
# Save button selected
save_json(file_path, data)
stdscr.refresh()
continue
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
need_redraw = True
menu_win.erase()
menu_win.refresh()
# menu_state.selected_index = menu_state.menu_index[-1]
# Navigate back in the menu
if len(menu_state.menu_path) > 2:
menu_state.menu_path.pop()
menu_state.start_index.pop()
menu_state.current_menu = data
for path in menu_state.menu_path[2:]:
menu_state.current_menu = (
menu_state.current_menu[path]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(path.strip("[]"))]
)
else:
# Exit the editor
menu_win.clear()
menu_win.refresh()
break
def save_json(file_path: str, data: Dict[str, Any]) -> None:
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.window) -> None:
from contact.ui.ui_state import MenuState
menu_state = MenuState()
if len(menu_state.menu_path) == 0:
menu_state.menu_path = ["App Settings"] # Initialize if not set
curses.curs_set(0)
stdscr.keypad(True)
setup_colors()
json_editor(stdscr, menu_state)
if __name__ == "__main__":
curses.wrapper(main)

View File

@@ -0,0 +1,39 @@
from argparse import ArgumentParser
def setup_parser() -> ArgumentParser:
parser = ArgumentParser(
add_help=True,
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.",
)
connOuter = parser.add_argument_group(
"Connection", "Optional arguments to specify a device to connect to and how."
)
conn = connOuter.add_mutually_exclusive_group()
conn.add_argument(
"--port",
"--serial",
"-s",
help="The port to connect to via serial, e.g. `/dev/ttyUSB0`.",
nargs="?",
default=None,
const=None,
)
conn.add_argument(
"--host",
"--tcp",
"-t",
help="The hostname or IP address to connect to using TCP.",
nargs="?",
default=None,
const="localhost",
)
conn.add_argument(
"--ble", "-b", help="The BLE device MAC address or name to connect to.", nargs="?", default=None, const="any"
)
parser.add_argument(
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true"
)
return parser

View File

@@ -1,13 +1,13 @@
import yaml
import logging
from typing import List
from google.protobuf.json_format import MessageToDict
from meshtastic import BROADCAST_ADDR, mt_config
from meshtastic import mt_config
from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
# defs are from meshtastic/python/main
def traverseConfig(config_root, config, interface_config) -> bool:
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
snake_name = camel_to_snake(config_root)
@@ -20,6 +20,7 @@ def traverseConfig(config_root, config, interface_config) -> bool:
return True
def splitCompoundName(comp_name: str) -> List[str]:
"""Split compound (dot separated) preference name into parts"""
name: List[str] = comp_name.split(".")
@@ -28,6 +29,7 @@ def splitCompoundName(comp_name: str) -> List[str]:
name.append(comp_name)
return name
def setPref(config, comp_name, raw_val) -> bool:
"""Set a channel or preferences value"""
@@ -75,9 +77,7 @@ def setPref(config, comp_name, raw_val) -> bool:
if e:
val = e.number
else:
logging.info(
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
)
logging.info(f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it.")
logging.info(f"Choices in sorted order are:")
names = []
for f in enumType.values:
@@ -122,44 +122,35 @@ def setPref(config, comp_name, raw_val) -> bool:
return True
def config_import(interface, filename):
with open(filename, encoding="utf8") as file:
configuration = yaml.safe_load(file)
closeNow = True
interface.getNode('^local', False).beginSettingsTransaction()
interface.getNode("^local", False).beginSettingsTransaction()
if "owner" in configuration:
logging.info(f"Setting device owner to {configuration['owner']}")
waitForAckNak = True
interface.getNode('^local', False).setOwner(configuration["owner"])
interface.getNode("^local", False).setOwner(configuration["owner"])
if "owner_short" in configuration:
logging.info(
f"Setting device owner short to {configuration['owner_short']}"
)
logging.info(f"Setting device owner short to {configuration['owner_short']}")
waitForAckNak = True
interface.getNode('^local', False).setOwner(
long_name=None, short_name=configuration["owner_short"]
)
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
if "ownerShort" in configuration:
logging.info(
f"Setting device owner short to {configuration['ownerShort']}"
)
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
waitForAckNak = True
interface.getNode('^local', False).setOwner(
long_name=None, short_name=configuration["ownerShort"]
)
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
if "channel_url" in configuration:
logging.info(f"Setting channel url to {configuration['channel_url']}")
interface.getNode('^local').setURL(configuration["channel_url"])
interface.getNode("^local").setURL(configuration["channel_url"])
if "channelUrl" in configuration:
logging.info(f"Setting channel url to {configuration['channelUrl']}")
interface.getNode('^local').setURL(configuration["channelUrl"])
interface.getNode("^local").setURL(configuration["channelUrl"])
if "location" in configuration:
alt = 0
@@ -180,32 +171,25 @@ def config_import(interface, filename):
interface.localNode.setFixedPosition(lat, lon, alt)
if "config" in configuration:
localConfig = interface.getNode('^local').localConfig
localConfig = interface.getNode("^local").localConfig
for section in configuration["config"]:
traverseConfig(
section, configuration["config"][section], localConfig
)
interface.getNode('^local').writeConfig(
camel_to_snake(section)
)
traverseConfig(section, configuration["config"][section], localConfig)
interface.getNode("^local").writeConfig(camel_to_snake(section))
if "module_config" in configuration:
moduleConfig = interface.getNode('^local').moduleConfig
moduleConfig = interface.getNode("^local").moduleConfig
for section in configuration["module_config"]:
traverseConfig(
section,
configuration["module_config"][section],
moduleConfig,
)
interface.getNode('^local').writeConfig(
camel_to_snake(section)
)
interface.getNode("^local").writeConfig(camel_to_snake(section))
interface.getNode('^local', False).commitSettingsTransaction()
interface.getNode("^local", False).commitSettingsTransaction()
logging.info("Writing modified configuration to device")
def config_export(interface) -> str:
"""used in --export-config"""
configObj = {}
@@ -238,7 +222,7 @@ def config_export(interface) -> str:
if alt:
configObj["location"]["alt"] = alt
config = MessageToDict(interface.localNode.localConfig) #checkme - Used as a dictionary here and a string below
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
if config:
# Convert inner keys to correct snake/camelCase
prefs = {}
@@ -249,15 +233,15 @@ def config_export(interface) -> str:
prefs[pref] = config[pref]
# mark base64 encoded fields as such
if pref == "security":
if 'privateKey' in prefs[pref]:
prefs[pref]['privateKey'] = 'base64:' + prefs[pref]['privateKey']
if 'publicKey' in prefs[pref]:
prefs[pref]['publicKey'] = 'base64:' + prefs[pref]['publicKey']
if 'adminKey' in prefs[pref]:
for i in range(len(prefs[pref]['adminKey'])):
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
if "privateKey" in prefs[pref]:
prefs[pref]["privateKey"] = "base64:" + prefs[pref]["privateKey"]
if "publicKey" in prefs[pref]:
prefs[pref]["publicKey"] = "base64:" + prefs[pref]["publicKey"]
if "adminKey" in prefs[pref]:
for i in range(len(prefs[pref]["adminKey"])):
prefs[pref]["adminKey"][i] = "base64:" + prefs[pref]["adminKey"][i]
if mt_config.camel_case:
configObj["config"] = config #Identical command here and 2 lines below?
configObj["config"] = config # Identical command here and 2 lines below?
else:
configObj["config"] = config
@@ -273,9 +257,9 @@ def config_export(interface) -> str:
else:
configObj["module_config"] = prefs
config_txt = "# start of Meshtastic configure yaml\n" #checkme - "config" (now changed to config_out)
#was used as a string here and a Dictionary above
config_txt = "# start of Meshtastic configure yaml\n" # checkme - "config" (now changed to config_out)
# was used as a string here and a Dictionary above
config_txt += yaml.dump(configObj)
# logging.info(config_txt)
return config_txt
return config_txt

View File

@@ -0,0 +1,68 @@
from typing import Optional, Tuple, Dict, List
import re
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Parses an INI file and returns a mapping of keys to human-readable names and help text."""
field_mapping: Dict[str, str] = {}
help_text: Dict[str, str] = {}
current_section: Optional[str] = None
with open(ini_file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(";") or line.startswith("#"):
continue
# Handle sections like [config.device]
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
continue
# Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(",", 2)]
if len(parts) >= 2:
key = parts[0]
# If key is 'title', map directly to the section
if key == "title":
full_key = current_section
else:
full_key = f"{current_section}.{key}" if current_section else key
# Use the provided human-readable name or fallback to key
human_readable_name = parts[1] if parts[1] else key
field_mapping[full_key] = human_readable_name
# Handle help text or default
help = parts[2] if len(parts) == 3 and parts[2] else "No help available."
help_text[full_key] = help
else:
# Handle cases with only the key present
full_key = f"{current_section}.{key}" if current_section else key
field_mapping[full_key] = key
help_text[full_key] = "No help available."
return field_mapping, help_text
def transform_menu_path(menu_path: List[str]) -> List[str]:
"""Applies path replacements and normalizes entries in the menu path."""
path_replacements = {"Radio Settings": "config", "Module Settings": "module"}
transformed_path: List[str] = []
for part in menu_path[1:]: # Skip 'Main Menu'
# Apply fixed replacements
part = path_replacements.get(part, part)
# Normalize entries like "Channel 1", "Channel 2", etc.
if re.match(r"Channel\s+\d+", part, re.IGNORECASE):
part = "channel"
transformed_path.append(part)
return transformed_path

View File

@@ -2,29 +2,33 @@ import sqlite3
import time
import logging
from datetime import datetime
from typing import Optional, Union, Dict
from utilities.utils import decimal_to_hex
import default_config as config
import globals
from contact.utilities.utils import decimal_to_hex
import contact.ui.default_config as config
def get_table_name(channel):
from contact.utilities.singleton import ui_state, interface_state
def get_table_name(channel: str) -> str:
# Construct the table name
table_name = f"{str(globals.myNodeNum)}_{channel}_messages"
table_name = f"{str(interface_state.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):
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
"""Save messages to the database, ensuring the table exists."""
try:
quoted_table_name = get_table_name(channel)
schema = '''
schema = """
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
'''
"""
ensure_table_exists(quoted_table_name, schema)
with sqlite3.connect(config.db_file_path) as db_connection:
@@ -32,10 +36,10 @@ def save_message_to_db(channel, user_id, message_text):
timestamp = int(time.time())
# Insert the message
insert_query = f'''
insert_query = f"""
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
'''
"""
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_connection.commit()
@@ -47,7 +51,7 @@ def save_message_to_db(channel, user_id, message_text):
logging.error(f"Unexpected error in save_message_to_db: {e}")
def update_ack_nak(channel, timestamp, message, ack):
def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None:
try:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
@@ -59,7 +63,7 @@ def update_ack_nak(channel, timestamp, message, ack):
message_text = ?
"""
db_cursor.execute(update_query, (ack, str(globals.myNodeNum), timestamp, message))
db_cursor.execute(update_query, (ack, str(interface_state.myNodeNum), timestamp, message))
db_connection.commit()
except sqlite3.Error as e:
@@ -69,52 +73,54 @@ def update_ack_nak(channel, timestamp, message, ack):
logging.error(f"Unexpected error in update_ack_nak: {e}")
def load_messages_from_db():
"""Load messages from the database for all channels and update globals.all_messages and globals.channel_list."""
def load_messages_from_db() -> None:
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list."""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()]
# Iterate through each table and fetch its messages
for table_name in tables:
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})')]
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}'
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], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
# Extract the channel name from the table name
channel = table_name.split("_")[1]
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
channel = int(channel) if channel.isdigit() else channel
# Add the channel to globals.channel_list if not already present
if channel not in globals.channel_list and not is_chat_archived(channel):
globals.channel_list.append(channel)
# Ensure the channel exists in globals.all_messages
if channel not in globals.all_messages:
globals.all_messages[channel] = []
# Add the channel to ui_state.channel_list if not already present
if channel not in ui_state.channel_list and not is_chat_archived(channel):
ui_state.channel_list.append(channel)
# Add messages to globals.all_messages grouped by hourly timestamp
# Ensure the channel exists in ui_state.all_messages
if channel not in ui_state.all_messages:
ui_state.all_messages[channel] = []
# Add messages to ui_state.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')
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
@@ -123,17 +129,20 @@ def load_messages_from_db():
elif ack_type == "Nak":
ack_str = config.nak_str
if user_id == str(globals.myNodeNum):
if user_id == str(interface_state.myNodeNum):
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
else:
formatted_message = (f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ", message)
formatted_message = (
f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
message,
)
hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into globals.all_messages[channel]
# Flatten the hourly messages into ui_state.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()):
globals.all_messages[channel].append((f"-- {hour} --", ""))
globals.all_messages[channel].extend(messages)
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
ui_state.all_messages[channel].extend(messages)
except sqlite3.Error as e:
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
@@ -142,24 +151,26 @@ def load_messages_from_db():
logging.error(f"SQLite error in load_messages_from_db: {e}")
def init_nodedb():
def init_nodedb() -> None:
"""Initialize the node database and update it with nodes from the interface."""
try:
if not globals.interface.nodes:
if not interface_state.interface.nodes:
return # No nodes to initialize
ensure_node_table_exists() # Ensure the table exists before insertion
nodes_snapshot = list(interface_state.interface.nodes.values())
# Insert or update all nodes
for node in globals.interface.nodes.values():
for node in nodes_snapshot:
update_node_info_in_db(
user_id=node['num'],
long_name=node['user'].get('longName', ''),
short_name=node['user'].get('shortName', ''),
hw_model=node['user'].get('hwModel', ''),
is_licensed=node['user'].get('isLicensed', '0'),
role=node['user'].get('role', 'CLIENT'),
public_key=node['user'].get('publicKey', '')
user_id=node["num"],
long_name=node["user"].get("longName", ""),
short_name=node["user"].get("shortName", ""),
hw_model=node["user"].get("hwModel", ""),
is_licensed=node["user"].get("isLicensed", "0"),
role=node["user"].get("role", "CLIENT"),
public_key=node["user"].get("publicKey", ""),
)
logging.info("Node database initialized successfully.")
@@ -170,16 +181,16 @@ def init_nodedb():
logging.error(f"Unexpected error in init_nodedb: {e}")
def maybe_store_nodeinfo_in_db(packet):
def maybe_store_nodeinfo_in_db(packet: Dict[str, object]) -> None:
"""Save nodeinfo unless that record is already there, updating if necessary."""
try:
user_id = packet['from']
long_name = packet['decoded']['user']['longName']
short_name = packet['decoded']['user']['shortName']
hw_model = packet['decoded']['user']['hwModel']
is_licensed = packet['decoded']['user'].get('isLicensed', '0')
role = packet['decoded']['user'].get('role', 'CLIENT')
public_key = packet['decoded']['user'].get('publicKey', '')
user_id = packet["from"]
long_name = packet["decoded"]["user"]["longName"]
short_name = packet["decoded"]["user"]["shortName"]
hw_model = packet["decoded"]["user"]["hwModel"]
is_licensed = packet["decoded"]["user"].get("isLicensed", "0")
role = packet["decoded"]["user"].get("role", "CLIENT")
public_key = packet["decoded"]["user"].get("publicKey", "")
update_node_info_in_db(user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
@@ -189,27 +200,43 @@ def maybe_store_nodeinfo_in_db(packet):
logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}")
def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=None, is_licensed=None, role=None, public_key=None, chat_archived=None):
def update_node_info_in_db(
user_id: Union[int, str],
long_name: Optional[str] = None,
short_name: Optional[str] = None,
hw_model: Optional[str] = None,
is_licensed: Optional[Union[str, int]] = None,
role: Optional[str] = None,
public_key: Optional[str] = None,
chat_archived: Optional[int] = None,
) -> None:
"""Update or insert node information into the database, preserving unchanged fields."""
try:
ensure_node_table_exists() # Ensure the table exists before any operation
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names
table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({table_name})')]
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({table_name})")]
if "chat_archived" not in table_columns:
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
db_cursor.execute(update_table_query)
# Fetch existing values to preserve unchanged fields
db_cursor.execute(f'SELECT * FROM {table_name} WHERE user_id = ?', (user_id,))
db_cursor.execute(f"SELECT * FROM {table_name} WHERE user_id = ?", (user_id,))
existing_record = db_cursor.fetchone()
if existing_record:
existing_long_name, existing_short_name, existing_hw_model, existing_is_licensed, existing_role, existing_public_key, existing_chat_archived = existing_record[1:]
(
existing_long_name,
existing_short_name,
existing_hw_model,
existing_is_licensed,
existing_role,
existing_public_key,
existing_chat_archived,
) = existing_record[1:]
long_name = long_name if long_name is not None else existing_long_name
short_name = short_name if short_name is not None else existing_short_name
@@ -219,8 +246,16 @@ def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=No
public_key = public_key if public_key is not None else existing_public_key
chat_archived = chat_archived if chat_archived is not None else existing_chat_archived
long_name = long_name if long_name is not None else "Meshtastic " + str(decimal_to_hex(user_id)[-4:])
short_name = short_name if short_name is not None else str(decimal_to_hex(user_id)[-4:])
hw_model = hw_model if hw_model is not None else "UNSET"
is_licensed = is_licensed if is_licensed is not None else 0
role = role if role is not None else "CLIENT"
public_key = public_key if public_key is not None else ""
chat_archived = chat_archived if chat_archived is not None else 0
# Upsert logic
upsert_query = f'''
upsert_query = f"""
INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
@@ -231,8 +266,10 @@ def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=No
role = excluded.role,
public_key = excluded.public_key,
chat_archived = excluded.chat_archived
'''
db_cursor.execute(upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived))
"""
db_cursor.execute(
upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
)
db_connection.commit()
except sqlite3.Error as e:
@@ -241,10 +278,10 @@ def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=No
logging.error(f"Unexpected error in update_node_info_in_db: {e}")
def ensure_node_table_exists():
def ensure_node_table_exists() -> None:
"""Ensure the node database table exists."""
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote for safety
schema = '''
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote for safety
schema = """
user_id TEXT PRIMARY KEY,
long_name TEXT,
short_name TEXT,
@@ -253,11 +290,11 @@ def ensure_node_table_exists():
role TEXT,
public_key TEXT,
chat_archived INTEGER
'''
"""
ensure_table_exists(table_name, schema)
def ensure_table_exists(table_name, schema):
def ensure_table_exists(table_name: str, schema: str) -> None:
"""Ensure the given table exists in the database."""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
@@ -271,10 +308,10 @@ def ensure_table_exists(table_name, schema):
logging.error(f"Unexpected error in ensure_table_exists({table_name}): {e}")
def get_name_from_database(user_id, type="long"):
def get_name_from_database(user_id: int, type: str = "long") -> str:
"""
Retrieve a user's name (long or short) from the node database.
:param user_id: The user ID to look up.
:param type: "long" for long name, "short" for short name.
:return: The retrieved name or the hex of the user id
@@ -284,9 +321,9 @@ def get_name_from_database(user_id, type="long"):
db_cursor = db_connection.cursor()
# Construct table name
table_name = f"{str(globals.myNodeNum)}_nodedb"
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"' # Quote table name for safety
# Determine the correct column to fetch
column_name = "long_name" if type == "long" else "short_name"
@@ -305,11 +342,12 @@ def get_name_from_database(user_id, type="long"):
logging.error(f"Unexpected error in get_name_from_database: {e}")
return "Unknown"
def is_chat_archived(user_id):
def is_chat_archived(user_id: int) -> int:
try:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
table_name = f"{str(globals.myNodeNum)}_nodedb"
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"'
query = f"SELECT chat_archived FROM {nodeinfo_table} WHERE user_id = ?"
db_cursor.execute(query, (user_id,))
@@ -324,4 +362,3 @@ def is_chat_archived(user_id):
except Exception as e:
logging.error(f"Unexpected error in is_chat_archived: {e}")
return "Unknown"

View File

@@ -0,0 +1,377 @@
import base64
import binascii
import curses
import ipaddress
from typing import Any, Optional, List
from contact.ui.colors import get_color
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
def get_text_input(prompt: str) -> Optional[str]:
"""Handles user input with wrapped text for long prompts."""
height = 8
width = 80
margin = 2 # Left and right margin
input_width = width - (2 * margin) # Space available for text
max_input_rows = height - 4 # Space for input
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
input_win = curses.newwin(height, width, start_y, start_x)
input_win.bkgd(get_color("background"))
input_win.attrset(get_color("window_frame"))
input_win.border()
# Wrap the prompt text
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
row = 1
for line in wrapped_prompt:
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
row += 1
if row >= height - 3: # Prevent overflow
break
prompt_text = "Enter new value: "
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
input_win.refresh()
curses.curs_set(1)
max_length = 4 if "shortName" in prompt else None
user_input = ""
# Start user input after the prompt text
col_start = margin + len(prompt_text)
first_line_width = input_width - len(prompt_text) # Available space for first line
while True:
key = input_win.get_wch() # Waits for user input
if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow
input_win.erase()
input_win.refresh()
curses.curs_set(0)
return None # Exit without saving
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): # Enter key
break
elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace
if user_input:
user_input = user_input[:-1] # Remove last character
elif max_length is None or len(user_input) < max_length: # Enforce max length
if isinstance(key, str):
user_input += key
else:
user_input += chr(key)
# First line must be manually handled before using wrap_text()
first_line = user_input[:first_line_width] # Cut to max first line width
remaining_text = user_input[first_line_width:] # Remaining text for wrapping
wrapped_lines = wrap_text(remaining_text, wrap_width=input_width) if remaining_text else []
# Clear only the input area (without touching prompt text)
for i in range(max_input_rows):
if row + 1 + i < height - 1:
input_win.addstr(
row + 1 + i, margin, " " * min(input_width, width - margin - 1), get_color("settings_default")
)
# Redraw the prompt text so it never disappears
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
# Redraw wrapped input
input_win.addstr(row + 1, col_start, first_line, get_color("settings_default")) # First line next to prompt
for i, line in enumerate(wrapped_lines):
if row + 2 + i < height - 1:
input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default"))
input_win.refresh()
curses.curs_set(0)
input_win.erase()
input_win.refresh()
return user_input
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
def to_base64(byte_strings):
"""Convert byte values to Base64-encoded strings."""
return [base64.b64encode(b).decode() for b in byte_strings]
def is_valid_base64(s):
"""Check if a string is valid Base64."""
try:
decoded = base64.b64decode(s, validate=True)
return len(decoded) == 32 # Ensure it's exactly 32 bytes
except binascii.Error:
return False
cvalue = to_base64(current_value) # Convert current values to Base64
height = 9
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
repeated_win = curses.newwin(height, width, start_y, start_x)
repeated_win.bkgd(get_color("background"))
repeated_win.attrset(get_color("window_frame"))
repeated_win.keypad(True) # Enable keypad for special keys
curses.echo()
curses.curs_set(1)
# Editable list of values (max 3 values)
user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields
cursor_pos = 0 # Track which value is being edited
error_message = ""
while True:
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
# Display current values, allowing editing
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " # Highlight the current line
repeated_win.addstr(
3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
)
repeated_win.addstr(3 + i, 18, line) # Align text for easier editing
# Move cursor to the correct position inside the field
curses.curs_set(1)
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
# Show error message if needed
if error_message:
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
repeated_win.refresh()
key = repeated_win.getch()
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
repeated_win.erase()
repeated_win.refresh()
curses.noecho()
curses.curs_set(0)
return None
elif key == ord("\n"): # Enter key to save and return
if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes
curses.noecho()
curses.curs_set(0)
return user_values # Return the edited Base64 values
else:
error_message = "Error: Each key must be valid Base64 and 32 bytes long!"
elif key == curses.KEY_UP: # Move cursor up
cursor_pos = (cursor_pos - 1) % len(user_values)
elif key == curses.KEY_DOWN: # Move cursor down
cursor_pos = (cursor_pos + 1) % len(user_values)
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
if len(user_values[cursor_pos]) > 0:
user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character
else:
try:
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
error_message = "" # Clear error if user starts fixing input
except ValueError:
pass # Ignore invalid character inputs
def get_repeated_input(current_value: List[str]) -> Optional[str]:
height = 9
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
repeated_win = curses.newwin(height, width, start_y, start_x)
repeated_win.bkgd(get_color("background"))
repeated_win.attrset(get_color("window_frame"))
repeated_win.keypad(True) # Enable keypad for special keys
curses.echo()
curses.curs_set(1) # Show the cursor
# Editable list of values (max 3 values)
user_values = current_value[:3]
cursor_pos = 0 # Track which value is being edited
error_message = ""
while True:
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
# Display current values, allowing editing
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " # Highlight the current line
repeated_win.addstr(
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
)
repeated_win.addstr(3 + i, 18, line)
# Move cursor to the correct position inside the field
curses.curs_set(1)
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
# Show error message if needed
if error_message:
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
repeated_win.refresh()
key = repeated_win.getch()
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
repeated_win.erase()
repeated_win.refresh()
curses.noecho()
curses.curs_set(0)
return None
elif key == ord("\n"): # Enter key to save and return
curses.noecho()
curses.curs_set(0)
return ", ".join(user_values)
elif key == curses.KEY_UP: # Move cursor up
cursor_pos = (cursor_pos - 1) % len(user_values)
elif key == curses.KEY_DOWN: # Move cursor down
cursor_pos = (cursor_pos + 1) % len(user_values)
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
if len(user_values[cursor_pos]) > 0:
user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character
else:
try:
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
error_message = "" # Clear error if user starts fixing input
except ValueError:
pass # Ignore invalid character inputs
def get_fixed32_input(current_value: int) -> int:
cvalue = current_value
current_value = str(ipaddress.IPv4Address(current_value))
height = 10
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
fixed32_win = curses.newwin(height, width, start_y, start_x)
fixed32_win.bkgd(get_color("background"))
fixed32_win.attrset(get_color("window_frame"))
fixed32_win.keypad(True)
curses.echo()
curses.curs_set(1)
user_input = ""
while True:
fixed32_win.erase()
fixed32_win.border()
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
fixed32_win.addstr(3, 2, f"Current: {current_value}")
fixed32_win.addstr(5, 2, f"New value: {user_input}")
fixed32_win.refresh()
key = fixed32_win.getch()
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow to cancel
fixed32_win.erase()
fixed32_win.refresh()
curses.noecho()
curses.curs_set(0)
return cvalue # Return the current value unchanged
elif key == ord("\n"): # Enter key to validate and save
# Validate IP address
octets = user_input.split(".")
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
curses.noecho()
curses.curs_set(0)
fixed32_address = ipaddress.ip_address(user_input)
return int(fixed32_address) # Return the valid IP address
else:
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
fixed32_win.refresh()
curses.napms(1500) # Wait for 1.5 seconds before refreshing
user_input = "" # Clear invalid input
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
user_input = user_input[:-1]
else:
try:
char = chr(key)
if char.isdigit() or char == ".":
user_input += char # Append only valid characters (digits or dots)
except ValueError:
pass # Ignore invalid inputs
def get_list_input(prompt: str, current_option: Optional[str], list_options: List[str]) -> Optional[str]:
"""
Displays a scrollable list of list_options for the user to choose from.
"""
selected_index = list_options.index(current_option) if current_option in list_options else 0
height = min(len(list_options) + 5, curses.LINES)
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
list_win = curses.newwin(height, width, start_y, start_x)
list_win.bkgd(get_color("background"))
list_win.attrset(get_color("window_frame"))
list_win.keypad(True)
list_pad = curses.newpad(len(list_options) + 1, width - 8)
list_pad.bkgd(get_color("background"))
# Render header
list_win.erase()
list_win.border()
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
# Render options on the pad
for idx, color in enumerate(list_options):
if idx == selected_index:
list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True))
else:
list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
# Initial refresh
list_win.refresh()
list_pad.refresh(
0,
0,
list_win.getbegyx()[0] + 3,
list_win.getbegyx()[1] + 4,
list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2,
list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4,
)
max_index = len(list_options) - 1
visible_height = list_win.getmaxyx()[0] - 5
draw_arrows(list_win, visible_height, max_index, [0], show_save_option=False) # Initial call to draw arrows
while True:
key = list_win.getch()
if key == curses.KEY_UP:
old_selected_index = selected_index
selected_index = max(0, selected_index - 1)
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
elif key == curses.KEY_DOWN:
old_selected_index = selected_index
selected_index = min(len(list_options) - 1, selected_index + 1)
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
elif key == ord("\n"): # Enter key
list_win.clear()
list_win.refresh()
return list_options[selected_index]
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
list_win.clear()
list_win.refresh()
return current_option

View File

@@ -0,0 +1,43 @@
import logging
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
def initialize_interface(args):
try:
if args.ble:
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
elif args.host:
try:
if ":" in args.host:
tcp_hostname, tcp_port = args.host.split(":")
else:
tcp_hostname = args.host
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
except Exception as ex:
logging.error(f"Error connecting to {args.host}. {ex}")
else:
try:
client = meshtastic.serial_interface.SerialInterface(args.port)
except FileNotFoundError as ex:
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
except PermissionError as ex:
logging.error(
f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}"
)
except Exception as ex:
logging.error(f"Unexpected error initializing interface: {ex}")
except OSError as ex:
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
if client.devPath is None:
try:
client = meshtastic.tcp_interface.TCPInterface("localhost")
except Exception as ex:
logging.error(f"Error connecting to localhost:{ex}")
return client
except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}")

View File

@@ -2,10 +2,10 @@ from meshtastic.protobuf import channel_pb2
from google.protobuf.message import Message
import logging
import base64
from db_handler import update_node_info_in_db
import globals
import time
def save_changes(interface, menu_path, modified_settings):
def save_changes(interface, modified_settings, menu_state):
"""
Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance
@@ -16,22 +16,56 @@ def save_changes(interface, menu_path, modified_settings):
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
node = interface.getNode("^local")
admin_key_backup = None
if "admin_key" in modified_settings:
# Get reference to security config
security_config = node.localConfig.security
admin_keys = modified_settings["admin_key"]
if {'latitude', 'longitude', 'altitude'} & modified_settings.keys():
lat = float(modified_settings.get('latitude', 0.0))
lon = float(modified_settings.get('longitude', 0.0))
alt = int(modified_settings.get('altitude', 0))
# Filter out empty keys
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
if not valid_keys:
logging.warning("No valid admin keys provided. Skipping admin key update.")
else:
# Clear existing keys if needed
if security_config.admin_key:
logging.info("Clearing existing admin keys...")
del security_config.admin_key[:]
node.writeConfig("security")
time.sleep(2) # Give time for device to process
# Append new keys
for key in valid_keys:
logging.info(f"Adding admin key: {key}")
security_config.admin_key.append(key)
node.writeConfig("security")
logging.info("Admin keys updated successfully!")
# Backup 'admin_key' before removing it
admin_key_backup = modified_settings.get("admin_key", None)
# Remove 'admin_key' from modified_settings to prevent interference
del modified_settings["admin_key"]
# Return early if there are no other settings left to process
if not modified_settings:
return
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
config_category = menu_state.menu_path[2].lower() # for radio and module configs
if {"latitude", "longitude", "altitude"} & modified_settings.keys():
lat = float(modified_settings.get("latitude", 0.0))
lon = float(modified_settings.get("longitude", 0.0))
alt = int(modified_settings.get("altitude", 0))
interface.localNode.setFixedPosition(lat, lon, alt)
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return
elif menu_path[1] == "User Settings": # for user configs
elif menu_state.menu_path[1] == "User Settings": # for user configs
config_category = "User Settings"
long_name = modified_settings.get("longName")
short_name = modified_settings.get("shortName")
@@ -40,27 +74,26 @@ def save_changes(interface, menu_path, modified_settings):
node.setOwner(long_name, short_name, is_licensed)
# Update only the changed fields and preserve others
update_node_info_in_db(globals.myNodeNum, long_name=long_name, short_name=short_name, is_licensed=is_licensed)
logging.info(f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}")
logging.info(
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
)
return
elif menu_path[1] == "Channels": # for channel configs
elif menu_state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels"
try:
channel = menu_path[-1]
channel = menu_state.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
if key == "psk": # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value)
elif key == 'position_precision': # Special case: module_settings
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
@@ -105,7 +138,9 @@ def save_changes(interface, menu_path, modified_settings):
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}")
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:
@@ -119,13 +154,11 @@ def save_changes(interface, menu_path, modified_settings):
try:
node.writeConfig(config_category)
logging.info(f"Changes written to config category: {config_category}")
if admin_key_backup is not None:
modified_settings["admin_key"] = admin_key_backup
except Exception as e:
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
node.writeConfig(config_category)
logging.info(f"Changes written to config category: {config_category}")
except Exception as e:
logging.error(f"Error saving changes: {e}")

View File

@@ -0,0 +1,5 @@
from contact.ui.ui_state import ChatUIState, InterfaceState, AppState
ui_state = ChatUIState()
interface_state = InterfaceState()
app_state = AppState()

View File

@@ -1,15 +1,17 @@
import globals
import datetime
from meshtastic.protobuf import config_pb2
import default_config as config
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
def get_channels():
"""Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
node = globals.interface.getNode('^local')
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
node = interface_state.interface.getNode("^local")
device_channels = node.channels
# Clear and rebuild channel list
# globals.channel_list = []
# ui_state.channel_list = []
for device_channel in device_channels:
if device_channel.role:
@@ -20,58 +22,75 @@ def get_channels():
# If channel name is blank, use the modem preset
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[
modem_preset_enum
].name
channel_name = convert_to_camel_case(modem_preset_string)
# Add channel to globals.channel_list if not already present
if channel_name not in globals.channel_list:
globals.channel_list.append(channel_name)
# Add channel to ui_state.channel_list if not already present
if channel_name not in ui_state.channel_list:
ui_state.channel_list.append(channel_name)
# Initialize globals.all_messages[channel_name] if it doesn't exist
if channel_name not in globals.all_messages:
globals.all_messages[channel_name] = []
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
if channel_name not in ui_state.all_messages:
ui_state.all_messages[channel_name] = []
return ui_state.channel_list
return globals.channel_list
def get_node_list():
if globals.interface.nodes:
my_node_num = globals.myNodeNum
if interface_state.interface.nodes:
my_node_num = interface_state.myNodeNum
def node_sort(node):
if(config.node_sort == 'lastHeard'):
return -node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0
elif(config.node_sort == "name"):
return node['user']['longName']
elif(config.node_sort == "hops"):
return node['hopsAway'] if 'hopsAway' in node else 100
if config.node_sort == "lastHeard":
return -node["lastHeard"] if ("lastHeard" in node and isinstance(node["lastHeard"], int)) else 0
elif config.node_sort == "name":
return node["user"]["longName"]
elif config.node_sort == "hops":
return node["hopsAway"] if "hopsAway" in node else 100
else:
return node
sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort)
node_list = [node['num'] for node in sorted_nodes if node['num'] != my_node_num]
sorted_nodes = sorted(interface_state.interface.nodes.values(), key=node_sort)
# Move favorite nodes to the beginning
sorted_nodes = sorted(
sorted_nodes, key=lambda node: node["isFavorite"] if "isFavorite" in node else False, reverse=True
)
# Move ignored nodes to the end
sorted_nodes = sorted(sorted_nodes, key=lambda node: node["isIgnored"] if "isIgnored" in node else False)
node_list = [node["num"] for node in sorted_nodes if node["num"] != my_node_num]
return [my_node_num] + node_list # Ensuring your node is always first
return []
def refresh_node_list():
new_node_list = get_node_list()
if new_node_list != globals.node_list:
globals.node_list = new_node_list
if new_node_list != ui_state.node_list:
ui_state.node_list = new_node_list
return True
return False
def get_nodeNum():
myinfo = globals.interface.getMyNodeInfo()
myNodeNum = myinfo['num']
myinfo = interface_state.interface.getMyNodeInfo()
myNodeNum = myinfo["num"]
return myNodeNum
def decimal_to_hex(decimal_number):
return f"!{decimal_number:08x}"
def convert_to_camel_case(string):
words = string.split('_')
camel_case_string = ''.join(word.capitalize() for word in words)
words = string.split("_")
camel_case_string = "".join(word.capitalize() for word in words)
return camel_case_string
def get_time_val_units(time_delta):
value = 0
unit = ""
@@ -99,11 +118,13 @@ def get_time_val_units(time_delta):
unit = "s"
return (value, unit)
def get_readable_duration(seconds):
delta = datetime.timedelta(seconds = seconds)
delta = datetime.timedelta(seconds=seconds)
val, units = get_time_val_units(delta)
return f"{val} {units}"
def get_time_ago(timestamp):
now = datetime.datetime.now()
dt = datetime.datetime.fromtimestamp(timestamp)
@@ -113,4 +134,3 @@ def get_time_ago(timestamp):
if unit != "s":
return f"{value} {unit} ago"
return "now"

View File

@@ -1,12 +0,0 @@
interface = None
display_log = False
all_messages = {}
channel_list = []
notifications = []
packet_buffer = []
node_list = []
myNodeNum = 0
selected_channel = 0
selected_message = 0
selected_node = 0
current_window = 0

View File

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

68
main.py
View File

@@ -1,68 +0,0 @@
#!/usr/bin/env python3
'''
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org
V 1.2.0
'''
import curses
from pubsub import pub
import os
import logging
import traceback
from utilities.arg_parser import setup_parser
from utilities.interfaces import initialize_interface
from message_handlers.rx_handler import on_receive
from ui.curses_ui import main_ui, draw_splash
from utilities.utils import get_channels, get_node_list, get_nodeNum
from db_handler import init_nodedb, load_messages_from_db
import default_config as config
import globals
import os
# Set ncurses compatibility settings
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
os.environ["LANG"] = "C.UTF-8"
os.environ.setdefault("TERM", "xterm-256color")
if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color"
# Configure logging
# Run `tail -f client.log` in another terminal to view live
logging.basicConfig(
filename=config.log_file_path,
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s"
)
def main(stdscr):
try:
draw_splash(stdscr)
parser = setup_parser()
args = parser.parse_args()
logging.info("Initializing interface %s", args)
globals.interface = initialize_interface(args)
logging.info("Interface initialized")
globals.myNodeNum = get_nodeNum()
globals.channel_list = get_channels()
globals.node_list = get_node_list()
pub.subscribe(on_receive, 'meshtastic.receive')
init_nodedb()
load_messages_from_db()
logging.info("Starting main UI")
main_ui(stdscr)
except Exception as e:
logging.error("An error occurred: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
raise
if __name__ == "__main__":
try:
curses.wrapper(main)
except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc())

View File

@@ -1,104 +0,0 @@
import logging
import time
from utilities.utils import refresh_node_list
from datetime import datetime
from ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification
from db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db
import default_config as config
import globals
from datetime import datetime
def on_receive(packet, interface):
# Update packet log
globals.packet_buffer.append(packet)
if len(globals.packet_buffer) > 20:
# Trim buffer to 20 packets
globals.packet_buffer = globals.packet_buffer[-20:]
if globals.display_log:
draw_packetlog_win()
try:
if 'decoded' not in packet:
return
# Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list()
if(changed):
draw_node_list()
if packet['decoded']['portnum'] == 'NODEINFO_APP':
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
maybe_store_nodeinfo_in_db(packet)
elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
message_bytes = packet['decoded']['payload']
message_string = message_bytes.decode('utf-8')
refresh_channels = False
refresh_messages = False
if packet.get('channel'):
channel_number = packet['channel']
else:
channel_number = 0
if packet['to'] == globals.myNodeNum:
if packet['from'] in globals.channel_list:
pass
else:
globals.channel_list.append(packet['from'])
if(packet['from'] not in globals.all_messages):
globals.all_messages[packet['from']] = []
update_node_info_in_db(packet['from'], chat_archived=False)
refresh_channels = True
channel_number = globals.channel_list.index(packet['from'])
if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]:
add_notification(channel_number)
refresh_channels = True
else:
refresh_messages = True
# Add received message to the messages list
message_from_id = packet['from']
message_from_string = get_name_from_database(message_from_id, type='short') + ":"
if globals.channel_list[channel_number] not in globals.all_messages:
globals.all_messages[globals.channel_list[channel_number]] = []
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
# Retrieve the last timestamp if available
channel_messages = globals.all_messages[globals.channel_list[channel_number]]
if channel_messages:
# Check the last entry for a timestamp
for entry in reversed(channel_messages):
if entry[0].startswith("--"):
last_hour = entry[0].strip("- ").strip()
break
else:
last_hour = None
else:
last_hour = None
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
globals.all_messages[globals.channel_list[channel_number]].append((f"-- {current_hour} --", ""))
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string} ", message_string))
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
except KeyError as e:
logging.error(f"Error processing packet: {e}")

View File

@@ -1,178 +0,0 @@
from datetime import datetime
import google.protobuf.json_format
from meshtastic import BROADCAST_NUM
from meshtastic.protobuf import mesh_pb2, portnums_pb2
from db_handler import save_message_to_db, update_ack_nak, get_name_from_database
import default_config as config
import globals
ack_naks = {}
# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention
# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462
def onAckNak(packet):
from ui.curses_ui import draw_messages_window
request = packet['decoded']['requestId']
if(request not in ack_naks):
return
acknak = ack_naks.pop(request)
message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1]
confirm_string = " "
ack_type = None
if(packet['decoded']['routing']['errorReason'] == "NONE"):
if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = config.nak_str
ack_type = "Nak"
globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message)
update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type)
channel_number = globals.channel_list.index(acknak['channel'])
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
draw_messages_window()
def on_response_traceroute(packet):
"""on response for trace route"""
from ui.curses_ui import draw_channel_list, draw_messages_window, add_notification
refresh_channels = False
refresh_messages = False
UNK_SNR = -128 # Value representing unknown SNR
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
msg_str = "Traceroute to:\n"
route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response
# SNR list should have one more entry than the route, as the final destination adds its SNR also
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]):
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
+ " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)"
# End with origin of response
route_str += " --> " + (get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}") \
+ " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)"
msg_str += route_str + "\n" # Print the route towards destination
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
+ " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)"
# End with destination of response (us)
route_str += " --> " + (get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}") \
+ " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)"
msg_str += route_str + "\n" # Print the route back to us
if(packet['from'] not in globals.channel_list):
globals.channel_list.append(packet['from'])
refresh_channels = True
channel_number = globals.channel_list.index(packet['from'])
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
message_from_string = get_name_from_database(packet['from'], type='short') + ":\n"
if globals.channel_list[channel_number] not in globals.all_messages:
globals.all_messages[globals.channel_list[channel_number]] = []
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string}", msg_str))
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str)
def send_message(message, destination=BROADCAST_NUM, channel=0):
myid = globals.myNodeNum
send_on_channel = 0
channel_id = globals.channel_list[channel]
if isinstance(channel_id, int):
send_on_channel = 0
destination = channel_id
elif isinstance(channel_id, str):
send_on_channel = channel
sent_message_data = globals.interface.sendText(
text=message,
destinationId=destination,
wantAck=True,
wantResponse=False,
onResponse=onAckNak,
channelIndex=send_on_channel,
)
# Add sent message to the messages dictionary
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:
last_hour = None
# 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))
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,
)

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[project]
name = "contact"
version = "1.3.8"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
]
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.9,<3.14"
dependencies = [
"meshtastic (>=2.6.0,<3.0.0)"
]
[project.urls]
Homepage = "https://github.com/pdxlocations/contact"
Issues = "https://github.com/pdxlocations/contact/issues"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
contact = "contact.__main__:start"

View File

@@ -1,369 +0,0 @@
import curses
import logging
import os
from save_to_radio import save_changes
from utilities.config_io import config_export, config_import
from input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input
from ui.menus import generate_menu_from_protobuf
from ui.colors import setup_colors, get_color
from utilities.arg_parser import setup_parser
from utilities.interfaces import initialize_interface
from user_config import json_editor
import globals
width = 60
save_option = "Save Changes"
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
def display_menu(current_menu, menu_path, selected_index, show_save_option):
# Calculate the dynamic height based on the number of menu items
num_items = len(current_menu) + (1 if show_save_option else 0) # Add 1 for the "Save Changes" option if applicable
height = min(curses.LINES - 2, num_items + 5) # Ensure the menu fits within the terminal height
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create a new curses window with dynamic dimensions
menu_win = curses.newwin(height, width, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
menu_pad = curses.newpad(len(current_menu) + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Display the current menu path as a header
header = " > ".join(word.title() for word in menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Display the menu options
for idx, option in enumerate(current_menu):
field_info = current_menu[option]
current_value = field_info[1] if isinstance(field_info, tuple) else ""
display_option = f"{option}"[:width // 2 - 2] # Truncate option name if too long``
display_value = f"{current_value}"[:width // 2 - 4] # Truncate value if too long
try:
# Use red color for "Reboot" or "Shutdown"
color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse = (idx == selected_index))
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
except curses.error:
pass
# Show save option if applicable
if show_save_option:
save_position = height - 2
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse = (selected_index == len(current_menu))))
menu_win.refresh()
menu_pad.refresh(0, 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8)
return menu_win, menu_pad
def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_pad):
if(old_idx == new_idx): # no-op
return
max_index = len(options) + (1 if show_save_option else 0) - 1
if show_save_option and old_idx == max_index: # special case un-highlight "Save" option
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else:
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
if show_save_option and new_idx == max_index: # special case highlight "Save" option
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse = True))
else:
menu_pad.chgat(new_idx, 0,menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse = True))
menu_win.refresh()
start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)) - (1 if show_save_option and new_idx == max_index else 0)) # Leave room for borders
menu_pad.refresh(start_index, 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8)
def settings_menu(stdscr, interface):
curses.update_lines_cols()
menu = generate_menu_from_protobuf(interface)
current_menu = menu["Main Menu"]
menu_path = ["Main Menu"]
menu_index = []
selected_index = 0
modified_settings = {}
need_redraw = True
show_save_option = False
while True:
if(need_redraw):
options = list(current_menu.keys())
show_save_option = (
len(menu_path) > 2 and ("Radio Settings" in menu_path or "Module Settings" in menu_path)
) or (
len(menu_path) == 2 and "User Settings" in menu_path
) or (
len(menu_path) == 3 and "Channels" in menu_path
)
# Display the menu
menu_win, menu_pad = display_menu(current_menu, menu_path, selected_index, show_save_option)
need_redraw = False
# Capture user input
key = menu_win.getch()
max_index = len(options) + (1 if show_save_option else 0) - 1
if key == curses.KEY_UP:
old_selected_index = selected_index
selected_index = max_index if selected_index == 0 else selected_index - 1
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad)
elif key == curses.KEY_DOWN:
old_selected_index = selected_index
selected_index = 0 if selected_index == max_index else selected_index + 1
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad)
elif key == curses.KEY_RESIZE:
need_redraw = True
curses.update_lines_cols()
elif key == ord("\t") and show_save_option:
old_selected_index = selected_index
selected_index = max_index
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad)
elif key == curses.KEY_RIGHT or key == ord('\n'):
need_redraw = True
menu_win.erase()
menu_win.refresh()
if show_save_option and selected_index == len(options):
save_changes(interface, menu_path, modified_settings)
modified_settings.clear()
logging.info("Changes Saved")
if len(menu_path) > 1:
menu_path.pop()
current_menu = menu["Main Menu"]
for step in menu_path[1:]:
current_menu = current_menu.get(step, {})
selected_index = 0
continue
selected_option = options[selected_index]
if selected_option == "Exit":
break
elif selected_option == "Export Config":
filename = get_text_input("Enter a filename for the config file")
if not filename:
logging.warning("Export aborted: No filename provided.")
continue # Go back to the menu
if not filename.lower().endswith(".yaml"):
filename += ".yaml"
try:
config_text = config_export(globals.interface)
app_directory = os.path.dirname(os.path.abspath(__file__))
config_folder = "node-configs"
yaml_file_path = os.path.join(app_directory, config_folder, filename)
if os.path.exists(yaml_file_path):
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
if overwrite == "Yes":
logging.info("Export cancelled: User chose not to overwrite.")
continue # Return to menu
os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True)
with open(yaml_file_path, "w", encoding="utf-8") as file:
file.write(config_text)
logging.info(f"Config file saved to {yaml_file_path}")
break
except PermissionError:
logging.error(f"Permission denied: Unable to write to {yaml_file_path}")
except OSError as e:
logging.error(f"OS error while saving config: {e}")
except Exception as e:
logging.error(f"Unexpected error: {e}")
continue
elif selected_option == "Load Config":
app_directory = os.path.dirname(os.path.abspath(__file__))
config_folder = "node-configs"
folder_path = os.path.join(app_directory, config_folder)
file_list = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]
filename = get_list_input("Choose a config file", None, file_list)
if filename:
file_path = os.path.join(app_directory, config_folder, filename)
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
if overwrite == "Yes":
config_import(globals.interface, file_path)
break
continue
elif selected_option == "Reboot":
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
break
continue
elif selected_option == "Reset Node DB":
confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
break
continue
elif selected_option == "Shutdown":
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu")
break
continue
elif selected_option == "Factory Reset":
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
break
continue
elif selected_option == "App Settings":
menu_win.clear()
menu_win.refresh()
json_editor(stdscr) # Open the App Settings menu
continue
# need_redraw = True
field_info = current_menu.get(selected_option)
if isinstance(field_info, tuple):
field, current_value = field_info
if selected_option in ['longName', 'shortName', 'isLicensed']:
if selected_option in ['longName', 'shortName']:
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
new_value = current_value if new_value is None else new_value
current_menu[selected_option] = (field, new_value)
elif selected_option == 'isLicensed':
new_value = get_list_input(f"Current value for {selected_option}: {current_value}", str(current_value), ["True", "False"])
new_value = new_value == "True"
current_menu[selected_option] = (field, new_value)
for option, (field, value) in current_menu.items():
modified_settings[option] = value
elif selected_option in ['latitude', 'longitude', 'altitude']:
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
new_value = current_value if new_value is None else new_value
current_menu[selected_option] = (field, new_value)
for option in ['latitude', 'longitude', 'altitude']:
if option in current_menu:
modified_settings[option] = current_menu[option][1]
elif field.type == 8: # Handle boolean type
new_value = get_list_input(selected_option, str(current_value), ["True", "False"])
new_value = new_value == "True" or new_value is True
elif field.label == field.LABEL_REPEATED: # Handle repeated field
new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else [int(item) for item in new_value]
elif field.enum_type: # Enum field
enum_options = {v.name: v.number for v in field.enum_type.values}
new_value_name = get_list_input(selected_option, current_value, list(enum_options.keys()))
new_value = enum_options.get(new_value_name, current_value)
elif field.type == 7: # Field type 7 corresponds to FIXED32
new_value = get_fixed32_input(current_value)
elif field.type == 13: # Field type 13 corresponds to UINT32
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
new_value = current_value if new_value is None else int(new_value)
elif field.type == 2: # Field type 13 corresponds to INT64
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
new_value = current_value if new_value is None else float(new_value)
else: # Handle other field types
new_value = get_text_input(f"Current value for {selected_option}: {current_value}")
new_value = current_value if new_value is None else new_value
for key in menu_path[3:]: # Skip "Main Menu"
modified_settings = modified_settings.setdefault(key, {})
# Add the new value to the appropriate level
modified_settings[selected_option] = new_value
# Convert enum string to int
if field and field.enum_type:
enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
current_menu[selected_option] = (field, new_value)
else:
current_menu = current_menu[selected_option]
menu_path.append(selected_option)
menu_index.append(selected_index)
selected_index = 0
elif key == curses.KEY_LEFT:
need_redraw = True
menu_win.erase()
menu_win.refresh()
if len(menu_path) < 2:
modified_settings.clear()
# Navigate back to the previous menu
if len(menu_path) > 1:
menu_path.pop()
current_menu = menu["Main Menu"]
for step in menu_path[1:]:
current_menu = current_menu.get(step, {})
selected_index = menu_index.pop()
elif key == 27: # Escape key
menu_win.erase()
menu_win.refresh()
break
def main(stdscr):
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
filename="settings.log",
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s"
)
setup_colors()
curses.curs_set(0)
stdscr.keypad(True)
parser = setup_parser()
args = parser.parse_args()
globals.interface = initialize_interface(args)
settings_menu(stdscr, globals.interface)
if __name__ == "__main__":
curses.wrapper(main)

View File

@@ -1,697 +0,0 @@
import curses
import textwrap
import time
from utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
from settings import settings_menu
from input_handlers import get_list_input
from message_handlers.tx_handler import send_message, send_traceroute
from ui.colors import setup_colors, get_color
from db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
import default_config as config
import ui.dialog
import globals
def draw_node_details():
node = None
try:
node = globals.interface.nodesByNum[globals.node_list[globals.selected_node]]
except KeyError:
return
function_win.erase()
function_win.box()
nodestr = ""
width = function_win.getmaxyx()[1]
node_details_list = [f"{node['user']['longName']} "
if 'user' in node and 'longName' in node['user'] else "",
f"({node['user']['shortName']})"
if 'user' in node and 'shortName' in node['user'] else "",
f" | {node['user']['hwModel']}"
if 'user' in node and 'hwModel' in node['user'] else ""]
if globals.node_list[globals.selected_node] == globals.myNodeNum:
node_details_list.extend([f" | Bat: {node['deviceMetrics']['batteryLevel']}% ({node['deviceMetrics']['voltage']}v)"
if 'deviceMetrics' in node
and 'batteryLevel' in node['deviceMetrics']
and 'voltage' in node['deviceMetrics'] else "",
f" | Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}" if 'deviceMetrics' in node
and 'uptimeSeconds' in node['deviceMetrics'] else "",
f" | ChUtil: {node['deviceMetrics']['channelUtilization']:.2f}%" if 'deviceMetrics' in node
and 'channelUtilization' in node['deviceMetrics'] else "",
f" | AirUtilTX: {node['deviceMetrics']['airUtilTx']:.2f}%" if 'deviceMetrics' in node
and 'airUtilTx' in node['deviceMetrics'] else "",
])
else:
node_details_list.extend([f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "",
f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "",
f" | SNR: {node['snr']}dB"
if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0)
else "",
])
for s in node_details_list:
if len(nodestr) + len(s) < width - 2:
nodestr = nodestr + s
draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
def draw_help():
cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat"]
function_str = ""
for s in cmds:
if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2):
function_str += s
draw_centered_text_field(function_win, function_str, 0, get_color("commands"))
def draw_function_win():
if(globals.current_window == 2):
draw_node_details()
else:
draw_help()
def get_msg_window_lines():
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if globals.display_log else 0
return messages_box.getmaxyx()[0] - 2 - packetlog_height
def refresh_pad(window):
win_height = channel_box.getmaxyx()[0]
selected_item = globals.selected_channel
pad = channel_pad
box = channel_box
lines = box.getmaxyx()[0] - 2
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
if(window == 1):
pad = messages_pad
box = messages_box
lines = get_msg_window_lines()
selected_item = globals.selected_message
start_index = globals.selected_message
if globals.display_log:
packetlog_win.box()
packetlog_win.refresh()
if(window == 2):
pad = nodes_pad
box = nodes_box
lines = box.getmaxyx()[0] - 2
selected_item = globals.selected_node
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
pad.refresh(start_index, 0,
box.getbegyx()[0] + 1, box.getbegyx()[1] + 1,
box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2)
def highlight_line(highlight, window, line):
pad = channel_pad
select_len = 0
ch_color = get_color("channel_list")
nd_color = get_color("node_list")
if(window == 2):
pad = nodes_pad
select_len = len(get_name_from_database(globals.node_list[line], "long"))
pad.chgat(line, 1, select_len, nd_color | curses.A_REVERSE if highlight else nd_color)
if(window == 0):
channel = globals.channel_list[line]
win_width = channel_box.getmaxyx()[1]
if(isinstance(channel, int)):
channel = get_name_from_database(channel, type="long")
select_len = min(len(channel), win_width - 4)
if line == globals.selected_channel and highlight == False:
ch_color = get_color("channel_selected")
pad.chgat(line, 1, select_len, ch_color | curses.A_REVERSE if highlight else ch_color)
def add_notification(channel_number):
if channel_number not in globals.notifications:
globals.notifications.append(channel_number)
def remove_notification(channel_number):
if channel_number in globals.notifications:
globals.notifications.remove(channel_number)
def draw_text_field(win, text, color):
win.border()
win.addstr(1, 1, text, color)
def draw_centered_text_field(win, text, y_offset, color):
height, width = win.getmaxyx()
x = (width - len(text)) // 2
y = (height // 2) + y_offset
win.addstr(y, x, text, color)
win.refresh()
def draw_debug(value):
function_win.addstr(1, 1, f"debug: {value} ")
function_win.refresh()
def draw_splash(stdscr):
setup_colors()
curses.curs_set(0)
stdscr.clear()
stdscr.bkgd(get_color("background"))
height, width = stdscr.getmaxyx()
message_1 = "/ Λ"
message_2 = "/ / \\"
message_3 = "P W R D"
message_4 = "connecting..."
start_x = width // 2 - len(message_1) // 2
start_x2 = width // 2 - len(message_4) // 2
start_y = height // 2 - 1
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+1, start_x-1, message_2, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+2, start_x-2, message_3, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+4, start_x2, message_4, get_color("splash_text"))
stdscr.attrset(get_color("window_frame"))
stdscr.box()
stdscr.refresh()
curses.napms(500)
def draw_channel_list():
channel_pad.erase()
win_height, win_width = channel_box.getmaxyx()
start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders
channel_pad.resize(len(globals.all_messages), channel_box.getmaxyx()[1])
idx = 0
for channel in globals.channel_list:
# Convert node number to long name if it's an integer
if isinstance(channel, int):
if is_chat_archived(channel):
continue
channel = get_name_from_database(channel, type='long')
# Determine whether to add the notification
notification = " " + config.notification_symbol if idx in globals.notifications else ""
# Truncate the channel name if it's too long to fit in the window
truncated_channel = channel[:win_width - 5] + '-' if len(channel) > win_width - 5 else channel
if idx == globals.selected_channel:
if globals.current_window == 0:
channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_list", reverse=True))
remove_notification(globals.selected_channel)
else:
channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_selected"))
else:
channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_list"))
idx += 1
channel_box.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame"))
channel_box.box()
channel_box.attrset((get_color("window_frame")))
channel_box.refresh()
refresh_pad(0)
def draw_messages_window(scroll_to_bottom = False):
"""Update the messages window based on the selected channel and scroll position."""
messages_pad.erase()
channel = globals.channel_list[globals.selected_channel]
if channel in globals.all_messages:
messages = globals.all_messages[channel]
msg_line_count = 0
row = 0
for (prefix, message) in messages:
full_message = f"{prefix}{message}"
wrapped_lines = textwrap.wrap(full_message, messages_box.getmaxyx()[1] - 2)
msg_line_count += len(wrapped_lines)
messages_pad.resize(msg_line_count, messages_box.getmaxyx()[1])
for line in wrapped_lines:
if prefix.startswith("--"):
color = get_color("timestamps")
elif prefix.startswith(config.sent_message_prefix):
color = get_color("tx_messages")
else:
color = get_color("rx_messages")
messages_pad.addstr(row, 1, line, color)
row += 1
messages_box.attrset(get_color("window_frame_selected") if globals.current_window == 1 else get_color("window_frame"))
messages_box.box()
messages_box.attrset(get_color("window_frame"))
messages_box.refresh()
if(scroll_to_bottom):
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
else:
globals.selected_message = max(min(globals.selected_message, msg_line_count - get_msg_window_lines()), 0)
refresh_pad(1)
draw_packetlog_win()
def draw_node_list():
nodes_pad.erase()
nodes_pad.resize(len(globals.node_list), nodes_box.getmaxyx()[1])
for i, node in enumerate(globals.node_list):
if globals.selected_node == i and globals.current_window == 2:
nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list", reverse=True))
else:
nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list"))
nodes_box.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame"))
nodes_box.box()
nodes_box.attrset(get_color("window_frame"))
nodes_box.refresh()
refresh_pad(2)
def select_channel(idx):
old_selected_channel = globals.selected_channel
globals.selected_channel = max(0, min(idx, len(globals.channel_list) - 1))
draw_messages_window(True)
# For now just re-draw channel list when clearing notifications, we can probably make this more efficient
if globals.selected_channel in globals.notifications:
remove_notification(globals.selected_channel)
draw_channel_list()
return
highlight_line(False, 0, old_selected_channel)
highlight_line(True, 0, globals.selected_channel)
refresh_pad(0)
def scroll_channels(direction):
new_selected_channel = globals.selected_channel + direction
if new_selected_channel < 0:
new_selected_channel = len(globals.channel_list) - 1
elif new_selected_channel >= len(globals.channel_list):
new_selected_channel = 0
select_channel(new_selected_channel)
def scroll_messages(direction):
globals.selected_message += direction
msg_line_count = messages_pad.getmaxyx()[0]
globals.selected_message = max(0, min(globals.selected_message, msg_line_count - get_msg_window_lines()))
refresh_pad(1)
def select_node(idx):
old_selected_node = globals.selected_node
globals.selected_node = max(0, min(idx, len(globals.node_list) - 1))
highlight_line(False, 2, old_selected_node)
highlight_line(True, 2, globals.selected_node)
refresh_pad(2)
draw_function_win()
def scroll_nodes(direction):
new_selected_node = globals.selected_node + direction
if new_selected_node < 0:
new_selected_node = len(globals.node_list) - 1
elif new_selected_node >= len(globals.node_list):
new_selected_node = 0
select_node(new_selected_node)
def draw_packetlog_win():
columns = [10,10,15,30]
span = 0
if globals.display_log:
packetlog_win.erase()
height, width = packetlog_win.getmaxyx()
for column in columns[:-1]:
span += column
# Add headers
headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}"
packetlog_win.addstr(1, 1, headers[:width - 2],get_color("log_header", underline=True)) # Truncate headers if they exceed window width
for i, packet in enumerate(reversed(globals.packet_buffer)):
if i >= height - 3: # Skip if exceeds the window height
break
# Format each field
from_id = get_name_from_database(packet['from'], 'short').ljust(columns[0])
to_id = (
"BROADCAST".ljust(columns[1]) if str(packet['to']) == "4294967295"
else get_name_from_database(packet['to'], 'short').ljust(columns[1])
)
if 'decoded' in packet:
port = packet['decoded']['portnum'].ljust(columns[2])
payload = (packet['decoded']['payload']).ljust(columns[3])
else:
port = "NO KEY".ljust(columns[2])
payload = "NO KEY".ljust(columns[3])
# Combine and truncate if necessary
logString = f"{from_id} {to_id} {port} {payload}"
logString = logString[:width - 3]
# Add to the window
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
packetlog_win.attrset(get_color("window_frame"))
packetlog_win.box()
packetlog_win.refresh()
def handle_resize(stdscr, firstrun):
global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win
# Calculate window max dimensions
height, width = stdscr.getmaxyx()
# Define window dimensions and positions
channel_width = 3 * (width // 16)
nodes_width = 5 * (width // 16)
messages_width = width - channel_width - nodes_width
if firstrun:
entry_win = curses.newwin(3, width, 0, 0)
channel_box = curses.newwin(height - 6, channel_width, 3, 0)
messages_box = curses.newwin(height - 6, messages_width, 3, channel_width)
nodes_box = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
function_win = curses.newwin(3, width, height - 3, 0)
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
# Will be resized to what we need when drawn
messages_pad = curses.newpad(1, 1)
nodes_pad = curses.newpad(1,1)
channel_pad = curses.newpad(1,1)
entry_win.bkgd(get_color("background"))
channel_box.bkgd(get_color("background"))
messages_box.bkgd(get_color("background"))
nodes_box.bkgd(get_color("background"))
messages_pad.bkgd(get_color("background"))
nodes_pad.bkgd(get_color("background"))
channel_pad.bkgd(get_color("background"))
function_win.bkgd(get_color("background"))
packetlog_win.bkgd(get_color("background"))
channel_box.attrset(get_color("window_frame"))
entry_win.attrset(get_color("window_frame"))
nodes_box.attrset(get_color("window_frame"))
messages_box.attrset(get_color("window_frame"))
function_win.attrset(get_color("window_frame"))
else:
entry_win.erase()
channel_box.erase()
messages_box.erase()
nodes_box.erase()
function_win.erase()
packetlog_win.erase()
entry_win.resize(3, width)
channel_box.resize(height - 6, channel_width)
messages_box.resize(height - 6, messages_width)
messages_box.mvwin(3, channel_width)
nodes_box.resize(height - 6, nodes_width)
nodes_box.mvwin(3, channel_width + messages_width)
function_win.resize(3, width)
function_win.mvwin(height - 3, 0)
packetlog_win.resize(int(height / 3), messages_width)
packetlog_win.mvwin(height - int(height / 3) - 3, channel_width)
channel_box.box()
entry_win.box()
nodes_box.box()
messages_box.box()
function_win.box()
# Refresh all windows
entry_win.refresh()
channel_box.refresh()
function_win.refresh()
nodes_box.refresh()
messages_box.refresh()
entry_win.keypad(True)
curses.curs_set(1)
try:
draw_function_win()
draw_channel_list()
draw_messages_window(True)
draw_node_list()
except:
# Resize events can come faster than we can re-draw, which can cause a curses error.
# In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later.
pass
def main_ui(stdscr):
global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win
messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None
stdscr.keypad(True)
get_channels()
input_text = ""
handle_resize(stdscr, True)
while True:
draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
# Get user input from entry window
char = entry_win.get_wch()
# draw_debug(f"Keypress: {char}")
if char == curses.KEY_UP:
if globals.current_window == 0:
scroll_channels(-1)
elif globals.current_window == 1:
scroll_messages(-1)
elif globals.current_window == 2:
scroll_nodes(-1)
elif char == curses.KEY_DOWN:
if globals.current_window == 0:
scroll_channels(1)
elif globals.current_window == 1:
scroll_messages(1)
elif globals.current_window == 2:
scroll_nodes(1)
elif char == curses.KEY_HOME:
if globals.current_window == 0:
select_channel(0)
elif globals.current_window == 1:
globals.selected_message = 0
refresh_pad(1)
elif globals.current_window == 2:
select_node(0)
elif char == curses.KEY_END:
if globals.current_window == 0:
select_channel(len(globals.channel_list) - 1)
elif globals.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
refresh_pad(1)
elif globals.current_window == 2:
select_node(len(globals.node_list) - 1)
elif char == curses.KEY_PPAGE:
if globals.current_window == 0:
select_channel(globals.selected_channel - (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us
elif globals.current_window == 1:
globals.selected_message = max(globals.selected_message - get_msg_window_lines(), 0)
refresh_pad(1)
elif globals.current_window == 2:
select_node(globals.selected_node - (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us
elif char == curses.KEY_NPAGE:
if globals.current_window == 0:
select_channel(globals.selected_channel + (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us
elif globals.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
globals.selected_message = min(globals.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines())
refresh_pad(1)
elif globals.current_window == 2:
select_node(globals.selected_node + (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us
elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT:
delta = -1 if char == curses.KEY_LEFT else 1
old_window = globals.current_window
globals.current_window = (globals.current_window + delta) % 3
if old_window == 0:
channel_box.attrset(get_color("window_frame"))
channel_box.box()
channel_box.refresh()
highlight_line(False, 0, globals.selected_channel)
refresh_pad(0)
if old_window == 1:
messages_box.attrset(get_color("window_frame"))
messages_box.box()
messages_box.refresh()
refresh_pad(1)
elif old_window == 2:
draw_function_win()
nodes_box.attrset(get_color("window_frame"))
nodes_box.box()
nodes_box.refresh()
highlight_line(False, 2, globals.selected_node)
refresh_pad(2)
if globals.current_window == 0:
channel_box.attrset(get_color("window_frame_selected"))
channel_box.box()
channel_box.attrset(get_color("window_frame"))
channel_box.refresh()
highlight_line(True, 0, globals.selected_channel)
refresh_pad(0)
elif globals.current_window == 1:
messages_box.attrset(get_color("window_frame_selected"))
messages_box.box()
messages_box.attrset(get_color("window_frame"))
messages_box.refresh()
refresh_pad(1)
elif globals.current_window == 2:
draw_function_win()
nodes_box.attrset(get_color("window_frame_selected"))
nodes_box.box()
nodes_box.attrset(get_color("window_frame"))
nodes_box.refresh()
highlight_line(True, 2, globals.selected_node)
refresh_pad(2)
# Check for Esc
elif char == chr(27):
break
# Check for Ctrl + t
elif char == chr(20):
send_traceroute()
curses.curs_set(0) # Hide cursor
ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.")
curses.curs_set(1) # Show cursor again
handle_resize(stdscr, False)
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
if globals.current_window == 2:
node_list = globals.node_list
if node_list[globals.selected_node] not in globals.channel_list:
globals.channel_list.append(node_list[globals.selected_node])
if(node_list[globals.selected_node] not in globals.all_messages):
globals.all_messages[node_list[globals.selected_node]] = []
globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node])
if(is_chat_archived(globals.channel_list[globals.selected_channel])):
update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=False)
globals.selected_node = 0
globals.current_window = 0
draw_node_list()
draw_channel_list()
draw_messages_window(True)
elif len(input_text) > 0:
# Enter key pressed, send user input as message
send_message(input_text, channel=globals.selected_channel)
draw_messages_window(True)
# Clear entry window and reset input text
input_text = ""
entry_win.erase()
elif char in (curses.KEY_BACKSPACE, chr(127)):
if input_text:
input_text = input_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(' ') #
entry_win.move(y, x - 1)
entry_win.refresh()
elif char == "`": # ` Launch the settings interface
curses.curs_set(0)
settings_menu(stdscr, globals.interface)
curses.curs_set(1)
refresh_node_list()
handle_resize(stdscr, False)
elif char == chr(16):
# Display packet log
if globals.display_log is False:
globals.display_log = True
draw_messages_window(True)
else:
globals.display_log = False
packetlog_win.erase()
draw_messages_window(True)
elif char == curses.KEY_RESIZE:
input_text = ""
handle_resize(stdscr, False)
# ^D
elif char == chr(4):
if(globals.current_window == 0):
if(isinstance(globals.channel_list[globals.selected_channel], int)):
update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=True)
# Shift notifications up to account for deleted item
for i in range(len(globals.notifications)):
if globals.notifications[i] > globals.selected_channel:
globals.notifications[i] -= 1
del globals.channel_list[globals.selected_channel]
globals.selected_channel = min(globals.selected_channel, len(globals.channel_list) - 1)
select_channel(globals.selected_channel)
draw_channel_list()
draw_messages_window()
if(globals.current_window == 2):
curses.curs_set(0)
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from nodedb?", "no", ["yes", "no"])
if confirmation == "yes":
globals.interface.localNode.removeNode(globals.node_list[globals.selected_node])
globals.node_list.pop(globals.selected_node)
draw_messages_window()
draw_node_list()
else:
draw_messages_window()
curses.curs_set(1)
continue
else:
# Append typed character to input text
if(isinstance(char, str)):
input_text += char
else:
input_text += chr(char)

View File

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

View File

@@ -1,37 +0,0 @@
import argparse
def setup_parser():
parser = argparse.ArgumentParser(
add_help=True,
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.")
connOuter = parser.add_argument_group('Connection', 'Optional arguments to specify a device to connect to and how.')
conn = connOuter.add_mutually_exclusive_group()
conn.add_argument(
"--port",
"--serial",
"-s",
help="The port to connect to via serial, e.g. `/dev/ttyUSB0`.",
nargs="?",
default=None,
const=None,
)
conn.add_argument(
"--host",
"--tcp",
"-t",
help="The hostname or IP address to connect to using TCP.",
nargs="?",
default=None,
const="localhost",
)
conn.add_argument(
"--ble",
"-b",
help="The BLE device MAC address or name to connect to.",
nargs="?",
default=None,
const="any"
)
return parser

View File

@@ -1,23 +0,0 @@
import logging
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
import globals
def initialize_interface(args):
try:
if args.ble:
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
elif args.host:
return meshtastic.tcp_interface.TCPInterface(args.host)
else:
try:
return meshtastic.serial_interface.SerialInterface(args.port)
except PermissionError as ex:
logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}")
except Exception as ex:
logging.error(f"Unexpected error initializing interface: {ex}")
if globals.interface.devPath is None:
return meshtastic.tcp_interface.TCPInterface("meshtastic.local")
except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}")