mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 20:06:13 +02:00
Compare commits
579 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abe8390237 | |||
| 5fe0ac0ad4 | |||
| b98102ccac | |||
| a02c3cae9e | |||
| ca7349a1a8 | |||
| eeaa11b8b0 | |||
| 08eaf090b2 | |||
| 2f43420235 | |||
| af74663518 | |||
| b7981c0450 | |||
| 0f4976b9ee | |||
| 1991f2515b | |||
| a351c86ccb | |||
| c2e1a3cbe6 | |||
| c2d1339256 | |||
| cb7139a7e1 | |||
| 6332387704 | |||
| 3f2b8e2a1f | |||
| 40c37745b6 | |||
| 9edac47aa2 | |||
| 44f8aafb66 | |||
| 9e3805f5d0 | |||
| 457799d8df | |||
| de3ad2d51f | |||
| ad83bc7979 | |||
| 9ebf63491c | |||
| b19585db6d | |||
| c28d22379e | |||
| 1e5ccf6c29 | |||
| 81f5bde287 | |||
| c33eb469ac | |||
| 0fe6584e7a | |||
| 557d79d437 | |||
| daff3dcb4a | |||
| 77db7287d6 | |||
| 67873e8dd9 | |||
| e2ddf5f79f | |||
| 4a93641f04 | |||
| d5922a214b | |||
| 7ad1ee26a4 | |||
| 08238aa464 | |||
| 1046baf741 | |||
| 42e1b7b5d9 | |||
| 3ca4f7edf7 | |||
| 55081d4a2d | |||
| be2b2604df | |||
| 35981d8f8b | |||
| 8e998c03ba | |||
| d802dd4212 | |||
| 7557eb1fa6 | |||
| 6a4af5e602 | |||
| 1895e6a919 | |||
| 975bf7f03f | |||
| c7d5d3887d | |||
| 5c93d8487e | |||
| 5d2834a9fb | |||
| cfe485bf29 | |||
| e7f6bd0397 | |||
| 1e7dc6af46 | |||
| af40cc3c8e | |||
| 2561b70fed | |||
| 44f145b646 | |||
| 55e2dc478d | |||
| 0932800e1f | |||
| c333eb25e3 | |||
| 580aa1cefd | |||
| 30de09f71b | |||
| 93d31adecd | |||
| 5f969017f7 | |||
| 967dd05fad | |||
| c808f0930b | |||
| 87df4b4aa1 | |||
| 0511d6f69b | |||
| 78b5598f67 | |||
| 5e1bdb2cc1 | |||
| 4420d44838 | |||
| ead1774cd3 | |||
| 0d45cbd849 | |||
| 456f739f51 | |||
| 80c6cc44e5 | |||
| 35265d8ae8 | |||
| 4a2d7ed100 | |||
| 47c4f038fe | |||
| 630ba67ef0 | |||
| fd1188abcd | |||
| 94513d7177 | |||
| fbff9821be | |||
| 1fd281121b | |||
| 5653a43941 | |||
| 7f07aedb8a | |||
| e437ce74c6 | |||
| 4ff6d2018a | |||
| 1c634da687 | |||
| 738c21dd66 | |||
| 7d72448ebf | |||
| b4f3d1f14c | |||
| 416166b07c | |||
| 480798e117 | |||
| 704a3d8a87 | |||
| 96e108037c | |||
| 97aade3632 | |||
| e43584912b | |||
| fccde36ecb | |||
| e631f9b0cc | |||
| b52431616e | |||
| 8446d99df1 | |||
| 8e1e913fcd | |||
| b74137dc72 | |||
| c83f9b0005 | |||
| 9f4737d350 | |||
| 29e9a5f701 | |||
| f0f06671cc | |||
| b1595e479c | |||
| 25df69bfbc | |||
| 88140081b9 | |||
| 4326f57977 | |||
| 43abcd07b2 | |||
| 5c60559cb8 | |||
| 3c0d6a4466 | |||
| 7b9d8f6a23 | |||
| 44d6fcac24 | |||
| 788d1cbdca | |||
| 26e8150092 | |||
| 3a1c2d691b | |||
| 134e8d0d29 | |||
| eb1f7ae638 | |||
| 14ba342160 | |||
| 7460c3ea9d | |||
| 6534946bc7 | |||
| 4847813ae1 | |||
| 3f6efaae1d | |||
| 60f3fa8e36 | |||
| b42ca44ba7 | |||
| d4bbb8a542 | |||
| db248302e9 | |||
| 7aa4f76064 | |||
| f01e91defc | |||
| 8ee08ff44a | |||
| 6d9ea552bd | |||
| 2cd71bf086 | |||
| 08d55dec72 | |||
| 20532f70a3 | |||
| 659370e1eb | |||
| 7151cf3846 | |||
| 6e5256acce | |||
| 7d27567ae9 | |||
| 5f0d042252 | |||
| 6f68dfc609 | |||
| a32ddda79d | |||
| ac6a5774af | |||
| b12e612596 | |||
| d1499ad75f | |||
| 79d5e69ee0 | |||
| 498770bd88 | |||
| 1405df6039 | |||
| ac5e71d6f2 | |||
| 650a24a68c | |||
| 53f122e503 | |||
| bf0533807a | |||
| 094058bad7 | |||
| efeb047116 | |||
| 88c99e0983 | |||
| 983a37f68f | |||
| bea3495b79 | |||
| 54c24c50d3 | |||
| b7972f50a8 | |||
| bab1693c82 | |||
| f93844a01b | |||
| 26b740fe3c | |||
| b0f5930e01 | |||
| 5b05fdefa1 | |||
| b63153b3a1 | |||
| 3c5a832bef | |||
| fd8bc4b56a | |||
| 2d943dedc5 | |||
| 137f41970d | |||
| c833f1036b | |||
| e15e6d83f7 | |||
| 4ead2ffcde | |||
| f9ca35b3ae | |||
| 7c4a244e05 | |||
| 6eab75ec7e | |||
| caf4bf4eff | |||
| 74e1f49db8 | |||
| 95c874e643 | |||
| 3b28ebfa49 | |||
| d36c63f6b1 | |||
| e8a4f5c349 | |||
| b022aea71f | |||
| 5225a1c766 | |||
| 41400c0528 | |||
| 07928d930c | |||
| 26742d0c88 | |||
| 8b73bef30b | |||
| 4b583fe337 | |||
| e6e7267eb1 | |||
| 36eeeae64d | |||
| 7c988ae3d0 | |||
| 1a0c4833d5 | |||
| 84c500d018 | |||
| 1960a16fb0 | |||
| 3580aeda5a | |||
| bb97b983bb | |||
| da31b67d54 | |||
| d840159f9c | |||
| 9de4158a6c | |||
| 1e21644d74 | |||
| df0ed8452b | |||
| d4a5f0f728 | |||
| 3e2c48457d | |||
| d4f518df0c | |||
| 5213c8c84c | |||
| 33c2b0c948 | |||
| b021a4a8ac | |||
| c74fdec10b | |||
| cf314e02ff | |||
| 8ae600d010 | |||
| fdd82e1f77 | |||
| 9d129260fd | |||
| 2b80760696 | |||
| c2655c1809 | |||
| cee7103ec6 | |||
| d05312c157 | |||
| 5b166c4b66 | |||
| dbe2915635 | |||
| 2337d7b592 | |||
| 62080424bb | |||
| 1ae76848fe | |||
| 45ed430580 | |||
| 5f8ce16855 | |||
| b79249c4a0 | |||
| 85d1a940dc | |||
| b85d451e26 | |||
| 41a297c944 | |||
| 41d64d86d4 | |||
| bd336e3ee2 | |||
| cf585cdf87 | |||
| 417a583696 | |||
| 541dba6a75 | |||
| 720b8be64f | |||
| 2b5083e889 | |||
| 5975006cf7 | |||
| 69e09378f5 | |||
| b832239e22 | |||
| d8e22ef4af | |||
| ffc5d75a58 | |||
| 350c85ca6d | |||
| 4d5f0087cc | |||
| e33bc553f5 | |||
| 020acbda02 | |||
| d5b8f7d462 | |||
| bc16d804e9 | |||
| a0459edf62 | |||
| 86170766eb | |||
| 33e1b527bd | |||
| 23f9bd216c | |||
| 35b592d2a7 | |||
| c215aedc0d | |||
| 9cd567895b | |||
| c469633a30 | |||
| f8f0b3a8cf | |||
| 47276dcb6c | |||
| 9c06ed62a4 | |||
| e19a8d3395 | |||
| b68bfc41d6 | |||
| ffb5fa51c1 | |||
| 0e4828bf72 | |||
| a5d9632a67 | |||
| 24747ecd17 | |||
| dbb8dd4c43 | |||
| 6c003069d4 | |||
| ea5ba3b2a3 | |||
| 58b34a6a2f | |||
| 4277e0c924 | |||
| 2f562ce682 | |||
| 370ff115b4 | |||
| 04733b6a02 | |||
| 749fb43fd0 | |||
| 8d7d926762 | |||
| c809dad05d | |||
| c76f230c9f | |||
| 226dc4f59e | |||
| 3f50a2ef07 | |||
| 4a7ea9eb29 | |||
| 29368961fc | |||
| 7cb84ea6c7 | |||
| 0b1a19164a | |||
| cf1a55e258 | |||
| 0881998e5b | |||
| ac65943263 | |||
| 04b324b711 | |||
| 5512f9e677 | |||
| b4962d39f0 | |||
| 39a687da58 | |||
| f41c7756d3 | |||
| bafea6a172 | |||
| 68f05075ca | |||
| adfb8c930c | |||
| 1299a301c1 | |||
| 3a4ea8022b | |||
| bd19015693 | |||
| cb9c9ae289 | |||
| 2369e69e0a | |||
| 9c2b6f0744 | |||
| 70d28e53a9 | |||
| 96d8d1dc64 | |||
| a7ff041a48 | |||
| 5a580b9c01 | |||
| 0834414ba4 | |||
| df538b3aaf | |||
| 2710cafb21 | |||
| 338f632514 | |||
| 7e1f941760 | |||
| 87ea2b4675 | |||
| 5c85a432c8 | |||
| 22ca5410ee | |||
| 276e0e09b3 | |||
| 1c57e35ba5 | |||
| 358589bd66 | |||
| 74c13d194c | |||
| 07fd88a4d6 | |||
| 07934093e6 | |||
| 3ee4f9d7a2 | |||
| b81f6ef89e | |||
| 489950a2f7 | |||
| 08e00373aa | |||
| 2f0d35748a | |||
| 7db2974481 | |||
| 0a20929df6 | |||
| 30f6f95d8e | |||
| 9e8cf56b31 | |||
| fb535298be | |||
| 1f2903fc2d | |||
| 6466a5c355 | |||
| f8e88b3737 | |||
| a13e241636 | |||
| 8c1a58b293 | |||
| bf53e8a4cb | |||
| c6cd209192 | |||
| e5c7ebb388 | |||
| e37632de3f | |||
| d36c5e3e32 | |||
| bc7506b0d9 | |||
| 38c7277c9d | |||
| 20d0bd92bb | |||
| e0df30b5f0 | |||
| 83635845b6 | |||
| 2e705538fd | |||
| 4363fd2a73 | |||
| 5bd3205de5 | |||
| bcde3bd9d5 | |||
| 15a8c637e4 | |||
| d38efc0421 | |||
| b311c406da | |||
| b5e9e4d04c | |||
| ce87dd9376 | |||
| 5273d9139d | |||
| 04ac3d6ed4 | |||
| 1a1d3059db | |||
| 633510b7de | |||
| 7f4c1e94fd | |||
| a06fefb34e | |||
| 4e0b6a49b0 | |||
| e993009782 | |||
| ad7028e508 | |||
| ce9bbd1059 | |||
| 0c35601af3 | |||
| 93369f8d64 | |||
| e7d1f28076 | |||
| 472b4a5ed2 | |||
| 314e4c7fff | |||
| 528a94d2bd | |||
| fa1c086f5f | |||
| d8bb747152 | |||
| 18a465fde8 | |||
| c52e00d2b7 | |||
| e17d1ba4b4 | |||
| 48a49ce48d | |||
| 9d1676818f | |||
| b5edd00220 | |||
| d3a7b7ce07 | |||
| 42ca242ee1 | |||
| 3e7e0669c5 | |||
| bee273ab56 | |||
| 1842bcf43e | |||
| 7c68973e30 | |||
| c9ede1f71f | |||
| 42e9628d98 | |||
| 1bf760121d | |||
| bb4a601788 | |||
| d0ed3484ce | |||
| 738e0b9815 | |||
| 97997e23e8 | |||
| eaee66f836 | |||
| 9a99d3f17e | |||
| 73e717fbd8 | |||
| dc87fa42b2 | |||
| f650e0ab34 | |||
| 39b745f8b0 | |||
| 18e1408292 | |||
| 3e941a5b20 | |||
| a000fc88a5 | |||
| def7c8e29e | |||
| 9388e1f506 | |||
| 81bdfe09fa | |||
| 5e94b14b45 | |||
| c3f1a43a80 | |||
| 3316f00271 | |||
| 319b84455b | |||
| f107dce920 | |||
| ec5b9663b2 | |||
| 19d7c3c98c | |||
| ae0ef90fe2 | |||
| 56e5e0d278 | |||
| 5d509a88d9 | |||
| 946006bd7f | |||
| 344cee5508 | |||
| 0d671f361d | |||
| 2d781cad56 | |||
| 088dcb39d6 | |||
| b1e3e71b68 | |||
| 557af55ee8 | |||
| 9421c10e8f | |||
| b157ee14e4 | |||
| e03ddcaaa7 | |||
| 6832516b40 | |||
| 5bfd9f4af2 | |||
| 463a0c9084 | |||
| 811c7e7349 | |||
| 0c5b37c07c | |||
| 7e384c12bb | |||
| 48bc8c6337 | |||
| c3d7b8f79a | |||
| b5c4413e63 | |||
| 9fbdbaa174 | |||
| e99e522573 | |||
| 9d806c608b | |||
| 5a9489eff1 | |||
| beb28b1f31 | |||
| 7d688fa5f8 | |||
| 09b68c37ba | |||
| df7dbad73d | |||
| 060fb1ef59 | |||
| b14e99ff24 | |||
| 77523c1b15 | |||
| 9673b25ab3 | |||
| 2732506f3c | |||
| 523fe3e28e | |||
| 3663db6ed3 | |||
| 5832fbd2c9 | |||
| 655066ed73 | |||
| 5cb5c2ad25 | |||
| 69a6922827 | |||
| 806252ec7e | |||
| 2236132df4 | |||
| 564cd65496 | |||
| b3625b4937 | |||
| 20af50585b | |||
| d776f3d09b | |||
| 075debc51b | |||
| 34318e4814 | |||
| 48dab293ae | |||
| 76d11b01a7 | |||
| 69c812cfd4 | |||
| 2257c091e8 | |||
| 55fb2390de | |||
| 0b91fb18bd | |||
| 8948f2e504 | |||
| 5c413bf949 | |||
| b0ffa28e46 | |||
| f97c846378 | |||
| e0d7c8a083 | |||
| 11ce2be5fa | |||
| 1fc041538e | |||
| 0ac8e97ea2 | |||
| e6743d2098 | |||
| f472ff7cab | |||
| 7ac220aee1 | |||
| 43e38ecc5b | |||
| 99eddfc2ef | |||
| f9eb6ebd98 | |||
| 8f59606867 | |||
| d214da41c6 | |||
| da22eb5c48 | |||
| 94546f90a4 | |||
| f82cadb4e1 | |||
| f60c656566 | |||
| b5e2a4c269 | |||
| d4f73d318a | |||
| 8ffae50b87 | |||
| dd13768a44 | |||
| 3330028d27 | |||
| 3144910cd9 | |||
| 819470cb40 | |||
| d7d06ec1f8 | |||
| 9d03844371 | |||
| 929a931ce9 | |||
| cba9835568 | |||
| 58daf63d00 | |||
| bb13d223ca | |||
| 863251d670 | |||
| 5e042b7bcc | |||
| 4d15c7d894 | |||
| 22e28a9e5b | |||
| e72c3abd7f | |||
| 439face70b | |||
| 55ac9df681 | |||
| 5808504ee0 | |||
| cb4333df4f | |||
| 7534f0cc54 | |||
| adfb4addb7 | |||
| e99fed2e76 | |||
| 13fa94acaa | |||
| 418955198f | |||
| e3e4e0b839 | |||
| 5ecb63fde9 | |||
| 7cd54d14d8 | |||
| 93b5bd908a | |||
| de30dfe87b | |||
| d5a60d6ca3 | |||
| 8f2d55277f | |||
| cdf5c0b81e | |||
| ae51755f07 | |||
| 7715732e69 | |||
| 01a5dc8d93 | |||
| c7bd4dd3fc | |||
| a069af8364 | |||
| 03f4963966 | |||
| e439bc913a | |||
| d5fe9c677f | |||
| 145609faf9 | |||
| c2931a266e | |||
| 9629a35fe1 | |||
| 1ce72ecdf7 | |||
| e0fb093612 | |||
| 1f37da8d2d | |||
| a9472870f3 | |||
| d6611e8518 | |||
| 6274df7244 | |||
| 813a47ee14 | |||
| e0e71180b2 | |||
| 73a835688d | |||
| 5d2aaa802b | |||
| eb78285b8f | |||
| e8538c55ea | |||
| b1cb531911 | |||
| 8fa37fe6dc | |||
| 73d4647cfc | |||
| 31afb7b9c0 | |||
| 73f082c06c | |||
| ea49bdff35 | |||
| 21fd505fb9 | |||
| 62943f6292 | |||
| c76b7895dd | |||
| 285c90f71e | |||
| e4662229b4 | |||
| be21b434cf | |||
| 662e84adbe | |||
| 5a72adc75b | |||
| 707f98d203 | |||
| 4c1d5fb8ec | |||
| fb279ccf1a | |||
| 7d39e726b4 | |||
| d9aa67d254 | |||
| 81694e7ab3 | |||
| 99f31c8226 | |||
| f715d72467 | |||
| f335fc56cc | |||
| d8294a8383 | |||
| 79db09bd15 | |||
| e3fe36dc19 | |||
| 69584051f5 | |||
| 58ea1d7eb9 | |||
| c9776639a0 | |||
| d860ea706d | |||
| b7976206fc | |||
| f73d10328b | |||
| a8ff2b4133 | |||
| 09ad642d79 |
@@ -0,0 +1 @@
|
||||
frontend/prebuilt/** -diff
|
||||
@@ -0,0 +1,74 @@
|
||||
name: All Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: uv sync --dev
|
||||
|
||||
- name: Backend lint
|
||||
run: uv run ruff check app/ tests/
|
||||
|
||||
- name: Backend format check
|
||||
run: uv run ruff format --check app/ tests/
|
||||
|
||||
- name: Backend typecheck
|
||||
run: uv run pyright app/
|
||||
|
||||
- name: Backend tests
|
||||
run: PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend format check
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend tests
|
||||
run: npm run test:run
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
+11
-1
@@ -2,6 +2,8 @@
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
!scripts/build/
|
||||
!scripts/build/**
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
@@ -12,11 +14,19 @@ frontend/test-results/
|
||||
|
||||
# Frontend build output (built from source by end users)
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
frontend/prebuilt/
|
||||
frontend/.eslintcache
|
||||
|
||||
# Release artifacts
|
||||
remoteterm-prebuilt-frontend-v*.zip
|
||||
|
||||
# reference libraries
|
||||
references/
|
||||
|
||||
# ancillary LLM files
|
||||
.claude/
|
||||
|
||||
# local Docker compose files
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
.docker-certs/
|
||||
|
||||
@@ -7,46 +7,51 @@
|
||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend in parallel. All checks must pass green.
|
||||
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
||||
|
||||
## Overview
|
||||
|
||||
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over Serial, TCP, or BLE and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
|
||||
|
||||
**For detailed component documentation, see:**
|
||||
**For detailed component documentation, see these primary AGENTS.md files:**
|
||||
- `app/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption)
|
||||
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
|
||||
- `frontend/src/components/AGENTS.md` - Frontend visualizer feature (a particularly complex and long force-directed graph visualizer component; can skip this file unless you're working on that feature)
|
||||
|
||||
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
|
||||
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ Frontend (React) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ CrackerPanel (global collapsible, WebGPU cracking) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ useWebSocket ←──── Real-time updates │
|
||||
│ │ │
|
||||
│ api.ts ←──── REST API calls │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ │ │
|
||||
│ useWebSocket ←──── Real-time updates │
|
||||
│ │ │
|
||||
│ api.ts ←──── REST API calls │
|
||||
└───────────────────────────┼─────────────────────────────────────┘
|
||||
│ HTTP + WebSocket (/api/*)
|
||||
┌───────────────────────────┼──────────────────────────────────────┐
|
||||
│ Backend (FastAPI) │
|
||||
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
|
||||
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
|
||||
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
|
||||
│ ↓ └───────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ RadioManager + Event Handlers │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ Routers │→ │ Services │→ │ Repositories │→ │ SQLite DB │ │
|
||||
│ └──────────┘ └──────────┘ └──────────────┘ └────────────┘ │
|
||||
│ ↓ │ ┌───────────┐ │
|
||||
│ ┌──────────────────────────┐ └──────────────→ │ WebSocket │ │
|
||||
│ │ Radio runtime seam + │ │ Manager │ │
|
||||
│ │ RadioManager lifecycle │ └───────────┘ │
|
||||
│ │ / event adapters │ │
|
||||
│ └──────────────────────────┘ │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ Serial / TCP / BLE
|
||||
┌──────┴──────┐
|
||||
@@ -72,13 +77,15 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
||||
- Raw packet feed — a debug/observation tool ("radio aquarium"); interesting to watch or copy packets from, but not critical infrastructure
|
||||
- Map view — visual display of node locations from advertisements
|
||||
- Network visualizer — force-directed graph of mesh topology
|
||||
- Bot system — automated message responses
|
||||
- Fanout integrations (MQTT, bots, webhooks, Apprise, SQS) — see `app/fanout/AGENTS_fanout.md`
|
||||
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
|
||||
|
||||
## Error Handling Philosophy
|
||||
|
||||
**Background tasks** (WebSocket broadcasts, periodic sync, contact auto-loading, etc.) use fire-and-forget `asyncio.create_task`. Exceptions in these tasks are logged to the backend logs, which is sufficient for debugging. There is no need to track task references or add done-callbacks purely for error visibility. If there's a convenient way to bubble an error to the frontend (e.g., via `broadcast_error` for user-actionable problems), do so, but this is minor and best-effort.
|
||||
|
||||
Radio startup/setup is one place where that frontend bubbling is intentional: if post-connect setup hangs past its timeout, the backend both logs the failure and pushes a toast instructing the operator to reboot the radio and restart the server.
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Store-and-serve**: Backend stores all packets even when no client is connected
|
||||
@@ -88,13 +95,22 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
||||
5. **Offline-capable**: Radio operates independently; server syncs when connected
|
||||
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer fewer, stronger modules over many tiny wrapper files.
|
||||
- Split code only when the new module owns a real invariant, workflow, or contract.
|
||||
- Avoid "enterprise" indirection layers whose main job is forwarding, renaming, or prop bundling.
|
||||
- For this repo, "locally dense but semantically obvious" is better than context scattered across many files.
|
||||
- Use typed contracts at important boundaries such as API payloads, WebSocket events, and repository writes.
|
||||
- Refactors should be behavior-preserving slices with tests around the moved seam, not aesthetic reshuffles.
|
||||
|
||||
## Intentional Security Design Decisions
|
||||
|
||||
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
|
||||
|
||||
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
|
||||
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this.
|
||||
2. **Minimal optional access control only**: The app has no user accounts, sessions, authorization model, or per-feature permissions. Operators may optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but this is only a coarse gate and still requires HTTPS plus a trusted network posture.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
|
||||
|
||||
## Intentional Packet Handling Decision
|
||||
|
||||
@@ -104,14 +120,31 @@ Raw packet handling uses two identities by design:
|
||||
|
||||
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
|
||||
|
||||
Channel metadata updates may also fan out as `channel` WebSocket events (full `Channel` payload) so clients can reflect local-only channel state such as regional flood-scope overrides without a full refetch.
|
||||
|
||||
## Contact Advert Path Memory
|
||||
|
||||
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per contact in a dedicated table (`contact_advert_paths`).
|
||||
|
||||
- This is independent of raw-packet payload deduplication.
|
||||
- Paths are keyed per contact + path, with `heard_count`, `first_seen`, and `last_seen`.
|
||||
- Paths are keyed per contact + path + hop count, with `heard_count`, `first_seen`, and `last_seen`.
|
||||
- Only the N most recent unique paths are retained per contact (currently 10).
|
||||
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
- See `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
|
||||
## Path Hash Modes
|
||||
|
||||
MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
|
||||
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
||||
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
||||
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
|
||||
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
|
||||
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
|
||||
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
|
||||
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
|
||||
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -126,14 +159,26 @@ To improve repeater disambiguation in the network visualizer, the backend stores
|
||||
|
||||
1. User types message → clicks send
|
||||
2. `api.sendChannelMessage()` → POST to backend
|
||||
3. Backend calls `radio_manager.meshcore.commands.send_chan_msg()`
|
||||
3. Backend route delegates to service-layer send orchestration, which acquires the radio lock and calls MeshCore commands
|
||||
4. Message stored in database with `outgoing=true`
|
||||
5. For direct messages: ACK tracked; for channel: repeat detection
|
||||
|
||||
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
|
||||
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
|
||||
- If the initial `MSG_SENT` result includes an expected ACK code, background retries are armed.
|
||||
- Non-final retry attempts use the effective route (`override > direct > flood`).
|
||||
- Retry timing follows the radio's `suggested_timeout`.
|
||||
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
|
||||
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
|
||||
|
||||
### ACK and Repeat Detection
|
||||
|
||||
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
||||
|
||||
Outgoing DMs send once immediately, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
|
||||
|
||||
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
|
||||
|
||||
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
|
||||
|
||||
This message-layer echo/path handling is independent of raw-packet storage deduplication.
|
||||
@@ -144,21 +189,19 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
.
|
||||
├── app/ # FastAPI backend
|
||||
│ ├── AGENTS.md # Backend documentation
|
||||
│ ├── bot.py # Bot execution and outbound bot sends
|
||||
│ ├── main.py # App entry, lifespan
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── services/ # Shared backend orchestration/domain services, including radio_runtime access seam
|
||||
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings)
|
||||
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
│ ├── event_handlers.py # Radio events
|
||||
│ ├── decoder.py # Packet decryption
|
||||
│ ├── websocket.py # Real-time broadcasts
|
||||
│ ├── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
|
||||
│ ├── mqtt.py # Private MQTT publisher
|
||||
│ └── community_mqtt.py # Community MQTT publisher (raw packet sharing)
|
||||
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── frontend/ # React frontend
|
||||
│ ├── AGENTS.md # Frontend documentation
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Main component
|
||||
│ │ ├── App.tsx # Frontend composition entry (hooks → AppShell)
|
||||
│ │ ├── api.ts # REST client
|
||||
│ │ ├── useWebSocket.ts # WebSocket hook
|
||||
│ │ └── components/
|
||||
@@ -166,10 +209,19 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ │ ├── MapView.tsx # Leaflet map showing node locations
|
||||
│ │ └── ...
|
||||
│ └── vite.config.ts
|
||||
├── scripts/
|
||||
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (parallelized)
|
||||
│ ├── publish.sh # Version bump, changelog, docker build & push
|
||||
│ └── deploy.sh # Deploy to production server
|
||||
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
||||
│ ├── build/
|
||||
│ │ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
│ │ └── publish.sh # Version bump, changelog, docker build & push
|
||||
│ ├── quality/
|
||||
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||
│ │ ├── e2e.sh # End-to-end test runner
|
||||
│ │ └── extended_quality.sh # Quality gate plus e2e and Docker matrix
|
||||
│ └── setup/
|
||||
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
|
||||
│ └── install_service.sh # Install/configure Linux systemd service
|
||||
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
||||
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
||||
├── tests/ # Backend tests (pytest)
|
||||
├── data/ # SQLite database (runtime)
|
||||
└── pyproject.toml # Python dependencies
|
||||
@@ -214,7 +266,7 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
Access at `http://localhost:8000`. All API routes are prefixed with `/api`.
|
||||
|
||||
If `frontend/dist` (or `frontend/dist/index.html`) is missing, backend startup now logs an explicit error and continues serving API routes. In that case, frontend static routes are not mounted until a frontend build is present.
|
||||
If `frontend/dist` is missing, the backend falls back to `frontend/prebuilt` when present (for example from the release zip artifact). If neither build directory is available, startup logs an explicit error and continues serving API routes without frontend static routes mounted.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -225,17 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
Key test files:
|
||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
|
||||
- `tests/test_keystore.py` - Ephemeral key store
|
||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing
|
||||
- `tests/test_api.py` - API endpoints, read state tracking
|
||||
- `tests/test_migrations.py` - Database migration system
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
|
||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
|
||||
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
|
||||
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
|
||||
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
|
||||
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
|
||||
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
|
||||
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
|
||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
|
||||
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
|
||||
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
|
||||
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
|
||||
- `tests/test_version_info.py` - Version/build metadata resolution
|
||||
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
|
||||
|
||||
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
@@ -244,9 +302,9 @@ cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
### Before Completing Changes
|
||||
### Before Completing Major Changes
|
||||
|
||||
**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds in parallel, catching type mismatches, breaking changes, and compilation errors.
|
||||
**Run `./scripts/quality/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
|
||||
## API Summary
|
||||
|
||||
@@ -254,55 +312,71 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Connection status |
|
||||
| GET | `/api/radio/config` | Radio configuration |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params |
|
||||
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement |
|
||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
|
||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
|
||||
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
|
||||
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
|
||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||
| POST | `/api/contacts/bulk-delete` | Delete multiple contacts |
|
||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||
| POST | `/api/contacts/sync` | Pull from radio |
|
||||
| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio |
|
||||
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
|
||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||
| POST | `/api/contacts/{public_key}/reset-path` | Reset contact path to flood |
|
||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
|
||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
|
||||
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch radio settings via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/node-info` | Fetch repeater name, location, and clock via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
|
||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}` | Get channel by key |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/sync` | Pull from radio |
|
||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||
| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||
| POST | `/api/messages/direct` | Send direct message |
|
||||
| POST | `/api/messages/channel` | Send channel message |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend an outgoing channel message (within 30 seconds) |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||
| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times |
|
||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
||||
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
|
||||
| GET | `/api/settings` | Get app settings |
|
||||
| PATCH | `/api/settings` | Update app settings |
|
||||
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
|
||||
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
|
||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new fanout config |
|
||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
@@ -320,12 +394,15 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
- `1` - Client (regular node)
|
||||
- `2` - Repeater
|
||||
- `3` - Room
|
||||
- `4` - Sensor
|
||||
|
||||
### Channel Keys
|
||||
|
||||
- Stored as 32-character hex string (TEXT PRIMARY KEY)
|
||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||
- Custom channels: User-provided or generated
|
||||
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
||||
- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default.
|
||||
|
||||
### Message Types
|
||||
|
||||
@@ -339,7 +416,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
|
||||
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
|
||||
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
|
||||
- Bulk update via `POST /api/read-state/mark-all-read`
|
||||
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
|
||||
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, and `last_read_ats`)
|
||||
|
||||
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
|
||||
- Channels: `channel-{channel_key}`
|
||||
@@ -347,33 +424,13 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
|
||||
|
||||
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
|
||||
|
||||
### MQTT Publishing
|
||||
### Fanout Bus (MQTT, Bots, Webhooks, Apprise, SQS)
|
||||
|
||||
Optional MQTT integration forwards mesh events to an external broker for home automation, logging, or alerting. All MQTT config is stored in the database (`app_settings`), not env vars — configured from the Settings pane, no server restart needed.
|
||||
All external integrations are managed through the fanout bus (`app/fanout/`). Each integration is a `FanoutModule` with scope-based event filtering, stored in the `fanout_configs` table and managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
|
||||
**Two independent toggles**: publish decrypted messages, publish raw packets.
|
||||
`broadcast_event()` in `websocket.py` dispatches `message` and `raw_packet` events to the fanout manager. See `app/fanout/AGENTS_fanout.md` for full architecture details.
|
||||
|
||||
**Topic structure** (default prefix `meshcore`):
|
||||
- `meshcore/dm:<contact_public_key>` — decrypted DM
|
||||
- `meshcore/gm:<channel_key>` — decrypted channel message
|
||||
- `meshcore/raw/dm:<contact_key>` — raw packet attributed to a DM contact
|
||||
- `meshcore/raw/gm:<channel_key>` — raw packet attributed to a channel
|
||||
- `meshcore/raw/unrouted` — raw packets that couldn't be attributed
|
||||
|
||||
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status` (`disabled` when no broker host is set, or when both publish toggles are off).
|
||||
|
||||
**Security**: MQTT password stored in plaintext in SQLite, consistent with the project's trusted-network design.
|
||||
|
||||
### Community MQTT Sharing
|
||||
|
||||
Separate from private MQTT, the community publisher (`app/community_mqtt.py`) shares raw packets with the MeshCore community aggregator for coverage mapping and analysis. Only raw packets are shared — never decrypted messages.
|
||||
|
||||
- Connects to community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
|
||||
- Authentication via Ed25519 JWT signed with the radio's private key. Tokens auto-renew before 24h expiry.
|
||||
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
|
||||
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code.
|
||||
- JWT `email` claim enables node claiming on the community aggregator.
|
||||
- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` in `app_settings`.
|
||||
Community MQTT forwards raw packets only. Its derived `path` field, when present on direct packets, is a comma-separated list of hop identifiers as reported by the packet format. Token width therefore varies with the packet's path hash mode; it is intentionally not a flat per-byte rendering.
|
||||
|
||||
### Server-Side Decryption
|
||||
|
||||
@@ -409,15 +466,34 @@ mc.subscribe(EventType.ACK, handler)
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
||||
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
**Transport mutual exclusivity:** Only one of `MESHCORE_SERIAL_PORT`, `MESHCORE_TCP_HOST`, or `MESHCORE_BLE_ADDRESS` may be set. If none are set, serial auto-detection is used.
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
### `meshcore_py` advert parsing can crash on malformed/truncated RF log packets
|
||||
|
||||
The vendored MeshCore Python reader's `LOG_DATA` advert path assumes the decoded advert payload always contains at least 101 bytes of advert body and reads the flags byte with `pk_buf.read(1)[0]` without a length guard. If a malformed or truncated RF log frame slips through, `MessageReader.handle_rx()` can fail with `IndexError: index out of range` from `meshcore/reader.py` while parsing payload type `0x04` (advert).
|
||||
|
||||
This does not indicate database corruption or a message-store bug. It is a parser-hardening gap in `meshcore_py`: the reader does not fully mirror firmware-side packet/path validation before attempting advert decode. The practical effect is usually a one-off asyncio task failure for that packet while later packets continue processing normally.
|
||||
|
||||
### Channel-message dedup intentionally treats same-name/same-text/same-second channel sends as indistinguishable because they are
|
||||
|
||||
Channel message storage deduplicates on `(type, conversation_key, text, sender_timestamp)`. Reviewers often flag this as "missing sender identity," but for channel messages the stored `text` already includes the displayed sender label (for example `Alice: hello`). That means two different users only collide when they produce the same rendered sender name, the same body text, and the same sender timestamp.
|
||||
|
||||
In that case, RemoteTerm usually does not have enough information to distinguish "two independent same-name sends" from "one message observed again as an echo/repeat." Without a reliable sender identity at ingest, treating those packets as the same message is an accepted limitation of the observable data model, not an obvious correctness bug.
|
||||
|
||||
+473
-171
@@ -1,233 +1,535 @@
|
||||
## [3.9.0] - 2026-04-06
|
||||
|
||||
* Feature: Add hop counts to hop-width selection options
|
||||
* Feature: Show cached repeater telemetry inline in settings
|
||||
* Feature: Retain recent traces and make them click-to-re-run
|
||||
* Feature: Autofocus channel/DM textbox on desktop
|
||||
* Feature: Favorites on the radio are now imported as favorites
|
||||
* Bugfix: Be clearer on issue identification for missing HTTPS context in channel finder
|
||||
* Bugfix: Don't use sender timestamp for message sequence display
|
||||
* Bugfix: Function on subdomains happily
|
||||
* Misc: Be gentler, room s/cracker/finder/
|
||||
* Misc: Test and frontend correctness & test fixes
|
||||
* Misc: Don't repeat clock sync failure logs
|
||||
* Misc: Make warning in readme clearer about taking over the radio
|
||||
* Misc: Improve readme phrasings
|
||||
* Misc: Better y-axis selection for battery read-out
|
||||
* Misc: Provide clearer warning on docker setup without docker installed
|
||||
* Misc: Default visualizer stale pruning to on/5 minutes
|
||||
* Misc: Migrate favorites to better storage pattern
|
||||
* Misc: Provide dumper script for API + WS interfaces for prep for HA integration
|
||||
|
||||
## [3.8.0] - 2026-04-03
|
||||
|
||||
* Feature: Per-channel hop width override
|
||||
* Feature: Intervalized repeater telemetry collection
|
||||
* Feature: Auto-resend option for byte-perfect resends on no repeater echo
|
||||
* Feature: Attach RSSI/SNR to received packets
|
||||
* Feature: Add motion packet display to map
|
||||
* Feature: Map dark mode
|
||||
* Bugfix: Make DB indices more useful around capitalization
|
||||
* Misc: Bump required Python to 3.11
|
||||
* Misc: Performance, documentation, and test improvements
|
||||
* Misc: More yields during long radio operations
|
||||
* Misc: Dead code & crufty test removal
|
||||
* Misc: Remove all but stub frontend favorites migration for very very old versions
|
||||
|
||||
## [3.7.1] - 2026-04-02
|
||||
|
||||
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
|
||||
|
||||
## [3.7.0] - 2026-04-02
|
||||
|
||||
* Feature: Repeater battery tracking
|
||||
* Feature: Repeater info pane just like contacts
|
||||
* Feature: Make repeaters blockable
|
||||
* Feature: Add new-node advert blocking
|
||||
* Feature: Add bulk deletion interface
|
||||
* Feature: Bulk room add on alt+click of new channel button
|
||||
* Feature: More info in debug endpoint
|
||||
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
|
||||
* Misc: Default auto-DM decrypt to true
|
||||
* Misc: Reorganize some settings panes
|
||||
* Misc: Enable FK pragma
|
||||
* Misc: Various performance and correctness fixes
|
||||
* Misc: Correct TCP default port
|
||||
|
||||
## [3.6.7] - 2026-03-31
|
||||
|
||||
* Misc: Remove armv7 (for now)
|
||||
|
||||
## [3.6.6] - 2026-03-31
|
||||
|
||||
* Misc: Please I'm begging for the build scripts to be working now
|
||||
|
||||
## [3.6.5] - 2026-03-31
|
||||
|
||||
* Bugfix: Maybe fix problem with publish script
|
||||
|
||||
## [3.6.4] - 2026-03-31
|
||||
|
||||
* Feature: Clarify New Channel/Contact button
|
||||
* Bugfix: Rename "Best RSSI" to "Strongest Neighbor"
|
||||
* Bugfix: Improve layout of Trace pane
|
||||
* Misc: Docker setup improvements
|
||||
|
||||
## [3.6.3] - 2026-03-30
|
||||
|
||||
* Feature: Add multi-byte trace
|
||||
* Feature: Show node name on discovered node if we know it
|
||||
* Feature: Add docker installation script
|
||||
* Feature: Add historical noise floor to stats
|
||||
* Feature: Add trace tool
|
||||
* Bugfix: 100x performance on statistics endpoint with indices and better queries
|
||||
* Misc: Performance and correctness improvements for backend-of-the-frontend
|
||||
* Misc: Reorganize scripts
|
||||
|
||||
## [3.6.2] - 2026-03-29
|
||||
|
||||
* Feature: Be more flexible about timing and volume of full contact offload
|
||||
* Feature: Improve room server and repeater ops to be much more clearer about auth status
|
||||
* Feature: Show last error status on integrations
|
||||
* Feature: Push multi-platform docker builds
|
||||
* Bugfix: Fix advert interval time unit display
|
||||
* Bugfix: Don't cast RSSI/SNR to string for community MQTT
|
||||
* Bugfix: Map uploader follows redirect
|
||||
* Misc: Thin out unnecessary cruft in unreads endpoint
|
||||
* Misc: Fall back gracefully if linked to an unknown contact
|
||||
|
||||
## [3.6.1] - 2026-03-26
|
||||
|
||||
* Feature: MeshCore Map integration
|
||||
* Feature: Add warning screen about bots
|
||||
* Feature: Favicon reflects unread message state
|
||||
* Feature: Show hop map in larger modal
|
||||
* Feature: Add prebuilt frontend install script
|
||||
* Feature: Add clean service installer script
|
||||
* Feature: Swipe in to show menu
|
||||
* Bugfix: Invalid backend API path serves error, not fallback index
|
||||
* Bugfix: Fix some spacing/page height issues
|
||||
* Misc: Misc. bugfixes and performance and test improvements
|
||||
|
||||
## [3.6.0] - 2026-03-22
|
||||
|
||||
* Feature: Add incoming-packet analytics
|
||||
* Feature: BYOPacket for analysis
|
||||
* Feature: Add room activity to stats view
|
||||
* Bugfix: Handle Heltec v3 serial noise
|
||||
* Misc: Swap repeaters and room servers for better ordering
|
||||
|
||||
## [3.5.0] - 2026-03-19
|
||||
|
||||
* Feature: Add room server alpha support
|
||||
* Feature: Add option to force-reset node clock when it's too far ahead
|
||||
* Feature: DMs auto-retry before resorting to flood
|
||||
* Feature: Add impulse zero-hop advert
|
||||
* Feature: Utilize PATH packets to correctly source a contact's route
|
||||
* Feature: Metrics view on raw packet pane
|
||||
* Feature: Metric, Imperial, and Smoots are now selectable for distance display
|
||||
* Feature: Allow favorites to be sorted
|
||||
* Feature: Add multi-ack support
|
||||
* Feature: Password-remember checkbox on repeaters + room servers
|
||||
* Bugfix: Serialize radio disconnect in a lock
|
||||
* Bugfix: Fix contact bar layout issues
|
||||
* Bugfix: Fix sidebar ordering for contacts by advert recency
|
||||
* Bugfix: Fix version reporting in community MQTT
|
||||
* Bugfix: Fix Apprise duplicate names
|
||||
* Bugfix: Be better about identity resolution in the stats pane
|
||||
* Misc: Docs, test, and performance enhancements
|
||||
* Misc: Don't prompt "Are you sure" when leaving an unedited interation
|
||||
* Misc: Log node time on startup
|
||||
* Misc: Improve community MQTT error bubble-up
|
||||
* Misc: Unread DMs always have a red unread counter
|
||||
* Misc: Improve information in the debug view to show DB status
|
||||
|
||||
## [3.4.1] - 2026-03-16
|
||||
|
||||
* Bugfix: Improve handling of version information on prebuilt bundles
|
||||
* Bugfix: Improve frontend usability on disconnected radio
|
||||
* Misc: Docs and readme updates
|
||||
* Misc: Overhaul DM ingest and frontend state handling
|
||||
|
||||
## [3.4.0] - 2026-03-16
|
||||
|
||||
* Feature: Add radio model and stats display
|
||||
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
|
||||
* Bugfix: Misc. frontend performance and correctness fixes
|
||||
* Bugfix: Fix same-second same-content DM send collition
|
||||
* Bugfix: Discard clearly-wrong GPS data
|
||||
* Bugfix: Prevent repeater clock skew drift on page nav
|
||||
* Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
|
||||
* Misc: Don't permit invalid fanout configs to be saved ever`
|
||||
|
||||
## [3.3.0] - 2026-03-13
|
||||
|
||||
* Feature: Use dashed lines to show collapsed ambiguous router results
|
||||
* Feature: Jump to unred
|
||||
* Feature: Local channel management to prevent need to reload channel every time
|
||||
* Feature: Debug endpoint
|
||||
* Feature: Force-singleton channel management
|
||||
* Feature: Local node discovery
|
||||
* Feature: Node routing discovery
|
||||
* Bugfix: Don't tell users to us npm ci
|
||||
* Bugfix: Fallback polling dm message persistence
|
||||
* Bugfix: All native-JS inputs are now modals
|
||||
* Bugfix: Same-second send collision resolution
|
||||
* Bugfix: Proper browser updates on resend
|
||||
* Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
||||
* Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
||||
* Misc: Visualizer data layer overhaul for future map work
|
||||
* Misc: Parallelize docker tests
|
||||
|
||||
## [3.2.0] - 2026-03-12
|
||||
|
||||
* Feature: Improve ambiguous-sender DM handling and visibility
|
||||
* Feature: Allow for toggling of node GPS broadcast
|
||||
* Feature: Add path width to bot and move example to full kwargs
|
||||
* Feature: Improve node map color contrast
|
||||
* Bugfix: More accurate tracking of contact data
|
||||
* Bugfix: Misc. frontend performance and bugfixes
|
||||
* Misc: Clearer warnings on user-key linkage
|
||||
* Misc: Documentation improvements
|
||||
|
||||
## [3.1.1] - 2026-03-11
|
||||
|
||||
* Feature: Add basic auth
|
||||
* Feature: SQS fanout
|
||||
* Feature: Enrich contact info pane
|
||||
* Feature: Search operators for node and channel
|
||||
* Feature: Pause radio connection attempts from Radio settings
|
||||
* Feature: New themes! What a great use of time!
|
||||
* Feature: Github workflows runs for validation
|
||||
* Bugfix: More consistent log format with times
|
||||
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.1.0] - 2026-03-11
|
||||
|
||||
* Feature: Add basic auth
|
||||
* Feature: SQS fanout
|
||||
* Feature: Enrich contact info pane
|
||||
* Feature: Search operators for node and channel
|
||||
* Feature: Pause radio connection attempts from Radio settings
|
||||
* Feature: New themes! What a great use of time!
|
||||
* Feature: Github workflows runs for validation
|
||||
* Bugfix: More consistent log format with times
|
||||
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.0.0] - 2026-03-10
|
||||
|
||||
* Feature: Custom regions per-channel
|
||||
* Feature: Add custom contact pathing
|
||||
* Feature: Corrupt packets are more clear that they're corrupt
|
||||
* Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||
* Feature: More consistent icons
|
||||
* Feature: Add per-channel local notifications
|
||||
* Feature: New themes
|
||||
* Feature: Massive codebase refactor and overhaul
|
||||
* Bugfix: Fix packet parsing for trace packets
|
||||
* Bugfix: Refetch channels on reconnect
|
||||
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
||||
* Bugfix: Timestamps in logs
|
||||
* Bugfix: Correct wrong clock sync command
|
||||
* Misc: Improve bot error bubble up
|
||||
* Misc: Update to non-lib-included meshcore-decoder version
|
||||
* Misc: Revise refactors to be more LLM friendly
|
||||
* Misc: Fix script executability
|
||||
* Misc: Better logging format with timestamp
|
||||
* Misc: Repeater advert buttons separate flood and one-hop
|
||||
* Misc: Preserve repeater pane on navigation away
|
||||
* Misc: Clearer iconography and coloring for status bar buttons
|
||||
* Misc: Search bar to top bar
|
||||
|
||||
## [2.7.9] - 2026-03-08
|
||||
|
||||
* Bugfix: Don't obscure new integration dropdown on session boundary
|
||||
|
||||
## [2.7.8] - 2026-03-08
|
||||
|
||||
|
||||
|
||||
## [2.7.8] - 2026-03-08
|
||||
|
||||
* Bugfix: Improve frontend asset resolution and fixup the build/push script
|
||||
|
||||
## [2.7.1] - 2026-03-08
|
||||
|
||||
* Bugfix: Fix historical DM packet length passing
|
||||
* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
||||
* Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
||||
|
||||
## [2.7.0] - 2026-03-08
|
||||
|
||||
* Feature: Multibyte path support
|
||||
* Feature: Add multibyte statistics to statistics pane
|
||||
* Feature: Add path bittage to contact info pane
|
||||
* Feature: Put tools in a collapsible
|
||||
|
||||
## [2.6.1] - 2026-03-08
|
||||
|
||||
* Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
||||
|
||||
## [2.6.0] - 2026-03-08
|
||||
|
||||
* Feature: A11y improvements
|
||||
* Feature: New themes
|
||||
* Feature: Backfill channel sender identity when available
|
||||
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
||||
* Bugfix: Unreads now respect blocklist
|
||||
* Bugfix: Unreads can't accumulate on an open thread
|
||||
* Bugfix: Channel name in broadcasts
|
||||
* Bugfix: Add missing httpx dependency
|
||||
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
||||
* Misc: Improved button signage for app movement
|
||||
* Misc: Test, performance, and documentation improvements
|
||||
|
||||
## [2.5.0] - 2026-03-05
|
||||
|
||||
* Feature: Far better accessibility across the app (with far to go)
|
||||
* Feature: Add community MQTT stats reporting, and improve over a few commits
|
||||
* Feature: Color schemes and misc. settings reorg
|
||||
* Feature: Add why-active to filtered nodes
|
||||
* Feature: Add channel and contact info box
|
||||
* Feature: Add contact blocking
|
||||
* Feature: Add potential repeater path map display
|
||||
* Feature: Add flood scoping/regions
|
||||
* Feature: Global message search
|
||||
* Feature: Fully safe bot disable
|
||||
* Feature: Add default #remoteterm channel (lol sorry I had to)
|
||||
* Feature: Custom recency pruning in visualizer
|
||||
* Bugfix: Be more cautious around null byte stripping
|
||||
* Bugfix: Clear channel-add interface on not-add-another
|
||||
* Bugfix: Add status/name/MQTT LWT
|
||||
* Bugfix: Channel deletion propagates over WS
|
||||
* Bugfix: Show map location for all nodes on link, not 7-day-limited
|
||||
* Bugfix: Hide private key channel keys by default
|
||||
* Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
||||
* Misc: Doc, changelog, and test improvements
|
||||
* Misc: Add, and remove, package lock (sorry Windows users)
|
||||
* Misc: Don't show mark all as read if not necessary
|
||||
* Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
||||
* Misc: Add Windows startup notes
|
||||
* Misc: E2E expansion + improvement
|
||||
* Misc: Move around visualizer settings
|
||||
|
||||
## [2.4.0] - 2026-03-02
|
||||
|
||||
* Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
||||
* Misc: Build scripts and library attribution
|
||||
* Misc: Add sign of life to E2E tests
|
||||
|
||||
## [2.3.0] - 2026-03-01
|
||||
|
||||
Feature: Click path description to reset to flood
|
||||
Feature: Add MQTT publishing
|
||||
Feature: Visualizer remembers settings
|
||||
Bugfix: Fix prefetch usage
|
||||
Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
||||
Misc: Drop py3.12 requirement
|
||||
Misc: Performance, documentation, test, and file structure optimizations
|
||||
Misc: Add arrows between route nodes on contact info
|
||||
Misc: Show repeater path/type in title bar
|
||||
* Feature: Click path description to reset to flood
|
||||
* Feature: Add MQTT publishing
|
||||
* Feature: Visualizer remembers settings
|
||||
* Bugfix: Fix prefetch usage
|
||||
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
||||
* Misc: Drop py3.12 requirement
|
||||
* Misc: Performance, documentation, test, and file structure optimizations
|
||||
* Misc: Add arrows between route nodes on contact info
|
||||
* Misc: Show repeater path/type in title bar
|
||||
|
||||
## [2.2.0] - 2026-02-28
|
||||
|
||||
Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
||||
Feature: Contact info pane
|
||||
Feature: Overhaul repeater interface
|
||||
Bugfix: Misc. frontend rendering + perf improvements
|
||||
Bugfix: Better behavior around radio locking and autofetch/polling
|
||||
Bugifx: Clear channel name field on new-channel modal tab change
|
||||
Bugfix: Repeater inforbox can scroll
|
||||
Bugfix: Better handling of historical DM encrypts
|
||||
Bugfix: Handle errors if returned in prefetch phase
|
||||
Misc: Radio event response failure is logged/surfaced better
|
||||
Misc: Improve test coverage and remove dead code
|
||||
Misc: Documentatin and errata improvements
|
||||
Misc: Databse storage optimization
|
||||
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
||||
* Feature: Contact info pane
|
||||
* Feature: Overhaul repeater interface
|
||||
* Bugfix: Misc. frontend rendering + perf improvements
|
||||
* Bugfix: Better behavior around radio locking and autofetch/polling
|
||||
* Bugfix: Clear channel name field on new-channel modal tab change
|
||||
* Bugfix: Repeater inforbox can scroll
|
||||
* Bugfix: Better handling of historical DM encrypts
|
||||
* Bugfix: Handle errors if returned in prefetch phase
|
||||
* Misc: Radio event response failure is logged/surfaced better
|
||||
* Misc: Improve test coverage and remove dead code
|
||||
* Misc: Documentation and errata improvements
|
||||
* Misc: Database storage optimization
|
||||
|
||||
## [2.1.0] - 2026-02-23
|
||||
|
||||
Feature: Add ability to remember last-used channel on load
|
||||
Feature: Add `docker compose` support (thanks @suymur !)
|
||||
Feature: Better-aligned favicon (lol)
|
||||
Bugfix: Disable autocomplete on message field
|
||||
Bugfix: Legacy hash restoration on page load
|
||||
Bugfix: Align resend buttons in pathing modal
|
||||
Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
||||
Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
||||
Bugfix: Improved repeater comms on busy meshes
|
||||
Bugfix: Drain before autofetch from radio
|
||||
Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
||||
Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
||||
Bugfix: Force server-side keystore update when radio key changes
|
||||
Bugfix: Reduce WS churn for incoming message handling
|
||||
Bugfix: Fix content type signalling for irrelevant endpoints
|
||||
Bugfix: Handle stuck post-connect failure state
|
||||
Misc: Documentation & version parsing improvements
|
||||
Misc: Hide char counter on mobile for short messages
|
||||
Misc: Typo fixes in docs and settings
|
||||
Misc: Add dynamic webmanifest for hosts that can support it
|
||||
Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
||||
Misc: Drop weird rounded bounding box for settings
|
||||
Misc: Move resend buttons to pathing modal
|
||||
Misc: Improved comments around database ownership on *nix systems
|
||||
Misc: Move to SSoT for message dedupe on frontend
|
||||
Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
||||
Misc: Holistic testing overhaul
|
||||
* Feature: Add ability to remember last-used channel on load
|
||||
* Feature: Add `docker compose` support (thanks @suymur !)
|
||||
* Feature: Better-aligned favicon (lol)
|
||||
* Bugfix: Disable autocomplete on message field
|
||||
* Bugfix: Legacy hash restoration on page load
|
||||
* Bugfix: Align resend buttons in pathing modal
|
||||
* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
||||
* Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
||||
* Bugfix: Improved repeater comms on busy meshes
|
||||
* Bugfix: Drain before autofetch from radio
|
||||
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
||||
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
||||
* Bugfix: Force server-side keystore update when radio key changes
|
||||
* Bugfix: Reduce WS churn for incoming message handling
|
||||
* Bugfix: Fix content type signalling for irrelevant endpoints
|
||||
* Bugfix: Handle stuck post-connect failure state
|
||||
* Misc: Documentation & version parsing improvements
|
||||
* Misc: Hide char counter on mobile for short messages
|
||||
* Misc: Typo fixes in docs and settings
|
||||
* Misc: Add dynamic webmanifest for hosts that can support it
|
||||
* Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
||||
* Misc: Drop weird rounded bounding box for settings
|
||||
* Misc: Move resend buttons to pathing modal
|
||||
* Misc: Improved comments around database ownership on *nix systems
|
||||
* Misc: Move to SSoT for message dedupe on frontend
|
||||
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
||||
* Misc: Holistic testing overhaul
|
||||
|
||||
## [2.0.1] - 2026-02-16
|
||||
|
||||
Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||
* Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||
|
||||
## [2.0.0] - 2026-02-16
|
||||
|
||||
Feature: Frontend UX + log overhaul
|
||||
Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||
Bugfix: Fix our of order path WS messages voerwriting each other
|
||||
Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||
Bugfix: Fir repeater command timestamp selection logic
|
||||
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||
Bugfix: Add missing radio operation locks in a few sports
|
||||
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||
Misc: Visualizer layout refinement & option labels
|
||||
* Feature: Frontend UX + log overhaul
|
||||
* Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||
* Bugfix: Fix out of order path WS messages overwriting each other
|
||||
* Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||
* Bugfix: Fix repeater command timestamp selection logic
|
||||
* Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||
* Bugfix: Add missing radio operation locks in a few spots
|
||||
* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||
* Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||
* Misc: Visualizer layout refinement & option labels
|
||||
|
||||
## [1.10.0] - 2026-02-16
|
||||
|
||||
Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
||||
Feature: 3D mesh visualizer
|
||||
Feature: Statistics pane
|
||||
Feature: Support incoming/outgoing indication for bot invocations
|
||||
Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
||||
Bugfix: Fix top padding out outgoing message
|
||||
Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
||||
Bugfix: Multiple-sent messages had path appearing delays until rerender
|
||||
Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
||||
Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
||||
Misc: s/stopped/idle/ for room finder
|
||||
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
||||
* Feature: 3D mesh visualizer
|
||||
* Feature: Statistics pane
|
||||
* Feature: Support incoming/outgoing indication for bot invocations
|
||||
* Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
||||
* Bugfix: Fix top padding out outgoing message
|
||||
* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
||||
* Bugfix: Multiple-sent messages had path appearing delays until rerender
|
||||
* Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
||||
* Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
||||
* Misc: s/stopped/idle/ for room finder
|
||||
|
||||
## [1.9.3] - 2026-02-12
|
||||
|
||||
Feature: Upgrade the room finder to support two-word rooms
|
||||
* Feature: Upgrade the room finder to support two-word rooms
|
||||
|
||||
## [1.9.2] - 2026-02-12
|
||||
|
||||
Feature: Options dialog sucks less
|
||||
Bugix: Move tests to isolated memory DB
|
||||
Bugfix: Mention case sensitivity
|
||||
Bugfix: Stale header retention on settings page view
|
||||
Bugfix: Non-isolated path writing
|
||||
Bugfix: Nullable contact fields are now passed as real nulls
|
||||
Bugfix: Look at all fields on message reconcile, not just text
|
||||
Bugfix: Make mark-all-as-read atomic
|
||||
Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
||||
Misc: Massive test and AGENTS.md overhauls and additions
|
||||
* Feature: Options dialog sucks less
|
||||
* Bugfix: Move tests to isolated memory DB
|
||||
* Bugfix: Mention case sensitivity
|
||||
* Bugfix: Stale header retention on settings page view
|
||||
* Bugfix: Non-isolated path writing
|
||||
* Bugfix: Nullable contact fields are now passed as real nulls
|
||||
* Bugfix: Look at all fields on message reconcile, not just text
|
||||
* Bugfix: Make mark-all-as-read atomic
|
||||
* Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
||||
* Misc: Massive test and AGENTS.md overhauls and additions
|
||||
|
||||
## [1.9.1] - 2026-02-10
|
||||
|
||||
Feature: Contacts and channels use keys, not names
|
||||
Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
||||
Bugfix: Show message length in bytes, not chars
|
||||
Bugfix: Fix phantom unread badges on focused convos
|
||||
Misc: Bot invocation to async
|
||||
Misc: Use full key, not prefix, where we can
|
||||
* Feature: Contacts and channels use keys, not names
|
||||
* Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
||||
* Bugfix: Show message length in bytes, not chars
|
||||
* Bugfix: Fix phantom unread badges on focused convos
|
||||
* Misc: Bot invocation to async
|
||||
* Misc: Use full key, not prefix, where we can
|
||||
|
||||
## [1.9.0] - 2026-02-10
|
||||
|
||||
Feature: Favorited contacts are preferentially loaded onto the radio
|
||||
Feature: Add recent-message caching for fast switching
|
||||
Feature: Add echo paths modal when echo-heard checkbox is clicked
|
||||
Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
||||
* Feature: Favorited contacts are preferentially loaded onto the radio
|
||||
* Feature: Add recent-message caching for fast switching
|
||||
* Feature: Add echo paths modal when echo-heard checkbox is clicked
|
||||
* Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
||||
Frontend: Better styling on echo + message path display
|
||||
Bugfix: Prevent frontend static file serving path traversal vuln
|
||||
Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
||||
Bugfix: Prevent injection from mentions with special characters
|
||||
Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
||||
Bugfix: App can boot and test without a frontend dir
|
||||
Misc: Improve and consistent-ify (?) backend radio operation lock management
|
||||
Misc: Frontend performance and safety enhancements
|
||||
Misc: Move builds to non-bundled; usage requires building the Frontend
|
||||
Misc: Update tests and agent docs
|
||||
* Bugfix: Prevent frontend static file serving path traversal vuln
|
||||
* Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
||||
* Bugfix: Prevent injection from mentions with special characters
|
||||
* Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
||||
* Bugfix: App can boot and test without a frontend dir
|
||||
* Misc: Improve and consistent-ify (?) backend radio operation lock management
|
||||
* Misc: Frontend performance and safety enhancements
|
||||
* Misc: Move builds to non-bundled; usage requires building the Frontend
|
||||
* Misc: Update tests and agent docs
|
||||
|
||||
## [1.8.0] - 2026-02-07
|
||||
|
||||
Feature: Single hop ping
|
||||
Feature: PWA viewport fixes(thanks @rgregg)
|
||||
* Feature: Single hop ping
|
||||
* Feature: PWA viewport fixes(thanks @rgregg)
|
||||
Feature (?): No frontend distribution; build it yourself ;P
|
||||
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||
Bugfix: Better guarding around reconnection
|
||||
Bugfix: Duplicate websocket connection fixes
|
||||
Bugfix: Settings tab error cleanliness on tab swap
|
||||
Bugfix: Fix path traversal vuln
|
||||
* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||
* Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||
* Bugfix: Better guarding around reconnection
|
||||
* Bugfix: Duplicate websocket connection fixes
|
||||
* Bugfix: Settings tab error cleanliness on tab swap
|
||||
* Bugfix: Fix path traversal vuln
|
||||
UI: Swap visualizer legend ordering (yay prettier)
|
||||
Misc: Perf and locking improvements
|
||||
Misc: Always flood advertisements
|
||||
Misc: Better packet dupe handling
|
||||
Misc: Dead code cleanup, test improvements
|
||||
|
||||
## [1.8.0] - 2026-02-07
|
||||
|
||||
Feature: Single hop ping
|
||||
Feature: PWA viewport fixes(thanks @rgregg)
|
||||
Feature (?): No frontend distribution; build it yourself ;P
|
||||
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||
Bugfix: Better guarding around reconnection
|
||||
Bugfix: Duplicate websocket connection fixes
|
||||
Bugfix: Settings tab error cleanliness on tab swap
|
||||
Bugfix: Fix path traversal vuln
|
||||
UI: Swap visualizer legend ordering (yay prettier)
|
||||
Misc: Perf and locking improvements
|
||||
Misc: Always flood advertisements
|
||||
Misc: Better packet dupe handling
|
||||
Misc: Dead code cleanup, test improvements
|
||||
* Misc: Perf and locking improvements
|
||||
* Misc: Always flood advertisements
|
||||
* Misc: Better packet dupe handling
|
||||
* Misc: Dead code cleanup, test improvements
|
||||
|
||||
## [1.7.1] - 2026-02-03
|
||||
|
||||
Feature: Clickable hyperlinks
|
||||
Bugfix: More consistent public key normalization
|
||||
Bugfix: Use more reliable cursor paging
|
||||
Bugfix: Fix null timestamp dedupe failure
|
||||
Bugfix: More concistent prefix-based message claiming on key reciept
|
||||
Misc: Bot can respond to its own messages
|
||||
Misc: Additional tests
|
||||
Misc: Remove unneeded message dedupe logic
|
||||
Misc: Resync settings after radio settings mutation
|
||||
* Feature: Clickable hyperlinks
|
||||
* Bugfix: More consistent public key normalization
|
||||
* Bugfix: Use more reliable cursor paging
|
||||
* Bugfix: Fix null timestamp dedupe failure
|
||||
* Bugfix: More consistent prefix-based message claiming on key receipt
|
||||
* Misc: Bot can respond to its own messages
|
||||
* Misc: Additional tests
|
||||
* Misc: Remove unneeded message dedupe logic
|
||||
* Misc: Resync settings after radio settings mutation
|
||||
|
||||
## [1.7.0] - 2026-01-27
|
||||
|
||||
Feature: Multi-bot functionality
|
||||
Bugfix: Adjust bot code editor display and add line numbers
|
||||
Bugfix: Fix clock filtering and contact lookup behavior bugs
|
||||
Bugfix: Fix repeater message duplication issue
|
||||
Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
||||
* Feature: Multi-bot functionality
|
||||
* Bugfix: Adjust bot code editor display and add line numbers
|
||||
* Bugfix: Fix clock filtering and contact lookup behavior bugs
|
||||
* Bugfix: Fix repeater message duplication issue
|
||||
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
||||
UI: Move advertise button to identity tab
|
||||
Misc: Clarify fallback functionality for missing private key export in logs
|
||||
* Misc: Clarify fallback functionality for missing private key export in logs
|
||||
|
||||
## [1.6.0] - 2026-01-26
|
||||
|
||||
Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
||||
Feature: Customizable advertising interval
|
||||
Feature: In-app bot setup
|
||||
Bugfix: Force contact onto radio before DM send
|
||||
Misc: Remove unused code
|
||||
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
||||
* Feature: Customizable advertising interval
|
||||
* Feature: In-app bot setup
|
||||
* Bugfix: Force contact onto radio before DM send
|
||||
* Misc: Remove unused code
|
||||
|
||||
## [1.5.0] - 2026-01-19
|
||||
|
||||
Feature: Network visualizer
|
||||
* Feature: Network visualizer
|
||||
|
||||
## [1.4.1] - 2026-01-19
|
||||
|
||||
Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
||||
Feature: Server-side preference management for favorites, read status, etc.
|
||||
* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
||||
* Feature: Server-side preference management for favorites, read status, etc.
|
||||
UI: More compact hop labelling
|
||||
Bugfix: Misc. race conditions and websocket handling
|
||||
Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
||||
* Bugfix: Misc. race conditions and websocket handling
|
||||
* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
||||
|
||||
## [1.4.0] - 2026-01-18
|
||||
|
||||
UI: Improve button layout for room searcher
|
||||
UI: Improve favicon coloring
|
||||
UI: Improve status bar button layout on small screen
|
||||
Feature: Show multi-path hop display with distance estimates
|
||||
Feature: Search rooms and contacts by key, not just name
|
||||
Bugfix: Historical DM decryption now works as expected
|
||||
Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
||||
* Feature: Show multi-path hop display with distance estimates
|
||||
* Feature: Search rooms and contacts by key, not just name
|
||||
* Bugfix: Historical DM decryption now works as expected
|
||||
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
||||
|
||||
## [1.3.1] - 2026-01-17
|
||||
|
||||
UI: Rework restart handling
|
||||
Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
||||
* Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
||||
Bug: Improve error message rendering from server-side errors
|
||||
UI: Remove octothorpe from channel listing
|
||||
|
||||
## [1.3.0] - 2026-01-17
|
||||
|
||||
Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
||||
Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
||||
Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
||||
* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
||||
* Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
||||
* Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
||||
|
||||
## [1.2.1] - 2026-01-17
|
||||
|
||||
@@ -235,27 +537,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
|
||||
|
||||
## [1.2.0] - 2026-01-16
|
||||
|
||||
Feature: Add favorites
|
||||
* Feature: Add favorites
|
||||
|
||||
## [1.1.0] - 2026-01-14
|
||||
|
||||
Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
||||
Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
||||
* Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
||||
* Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
||||
|
||||
## [1.0.3] - 2026-01-13
|
||||
|
||||
Bugfix: Add missing test management packages
|
||||
Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
||||
* Bugfix: Add missing test management packages
|
||||
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
||||
|
||||
## [1.0.2] - 2026-01-13
|
||||
|
||||
Improvement: Add delays between router ops to prevent traffic collisions
|
||||
* Improvement: Add delays between router ops to prevent traffic collisions
|
||||
|
||||
## [1.0.1] - 2026-01-13
|
||||
|
||||
Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
||||
* Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
||||
|
||||
## [1.0.0] - 2026-01-13
|
||||
|
||||
Initial full release!
|
||||
* Initial full release!
|
||||
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Contributing
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
- In all your interactions with developers, maintainers, and users, be kind.
|
||||
- Prefer small, comprehensible changes over large sweeping ones. Individual commits should be meaningful atomic chunks of work. Pull requests with many, many commits instead of a phased approach may be declined.
|
||||
- Pull requests must be fully understood and explicitly endorsed by a human before merge. AI assistance is great, and this repo is optimized for it, but we keep quality by keeping our agents on track to write clear code, useful (not useless) tests, good architecture, and big-picture thinking.
|
||||
- No pull request should introduce new failing lint, typecheck, test, or build results.
|
||||
- Every pull request should have an associated issue or discussion thread; a brand new feature appearing first in a PR is an antipattern.
|
||||
- No truly automated radio traffic. Bot replies are already the practical edge of what this project wants to automate; any kind of traffic that would be intervalized or automated is not what this project is about.
|
||||
- No ingestion from the internet onto the mesh. This project is a radio client, not a bridge for outside traffic to enter the network. The mesh is strong because it is a radio mesh, not the internet with some weird wireless links.
|
||||
|
||||
## Local Development
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
With an explicit serial port:
|
||||
|
||||
```bash
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
uv sync
|
||||
$env:MESHCORE_SERIAL_PORT="COM8"
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run both the backend and `npm run dev` for hot-reloading frontend development.
|
||||
|
||||
## Quality Checks
|
||||
|
||||
Run the full quality suite before proposing or handing off code changes:
|
||||
|
||||
```bash
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
||||
|
||||
If you need targeted commands while iterating:
|
||||
|
||||
```bash
|
||||
# backend
|
||||
uv run ruff check app/ tests/ --fix
|
||||
uv run ruff format app/ tests/
|
||||
uv run pyright app/
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
# frontend
|
||||
cd frontend
|
||||
npm run lint:fix
|
||||
npm run format
|
||||
npm run test:run
|
||||
npm run build
|
||||
```
|
||||
|
||||
## E2E Testing
|
||||
|
||||
E2E coverage exists, but it is intentionally not part of the normal development path.
|
||||
|
||||
These tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly, an available autodetect-able radio, and a contact in the test database (which you can provide in `tests/e2e/.tmp/e2e-test.db` after an initial run). E2E tests are generally not necessary to run for normal development work.
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npm install
|
||||
npx playwright test # headless
|
||||
npx playwright test --headed # you can probably guess
|
||||
```
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
- Keep scope tight.
|
||||
- Explain why the change is needed.
|
||||
- Link the issue or discussion where the behavior was agreed on.
|
||||
- Call out any follow-up work left intentionally undone.
|
||||
- Do not treat code review as the place where the app's direction is first introduced or debated
|
||||
|
||||
## Notes For Agent-Assisted Work
|
||||
|
||||
Before making non-trivial changes, read:
|
||||
|
||||
- `./AGENTS.md`
|
||||
- `./app/AGENTS.md`
|
||||
- `./frontend/AGENTS.md`
|
||||
|
||||
Read these only when working in those areas:
|
||||
|
||||
- `./app/fanout/AGENTS_fanout.md`
|
||||
- `./frontend/src/components/visualizer/AGENTS_packet_visualizer.md`
|
||||
|
||||
- Agent output is welcome, but human review is mandatory.
|
||||
- Agents should start with the AGENTS files above before making architectural changes.
|
||||
- If a change touches advanced areas like fanout or the visualizer, read the area-specific AGENTS file before editing.
|
||||
+5
-1
@@ -5,7 +5,7 @@ ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
@@ -15,8 +15,12 @@ RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
||||
# Stage 2: Python runtime
|
||||
FROM python:3.12-slim
|
||||
|
||||
ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV COMMIT_HASH=${COMMIT_HASH}
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
|
||||
+344
-6
@@ -1,6 +1,6 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand.
|
||||
Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand.
|
||||
|
||||
## Backend (Python) Dependencies
|
||||
|
||||
@@ -56,6 +56,227 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### apprise (1.9.7) — BSD-2-Clause
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### boto3 (1.42.66) — Apache-2.0
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### fastapi (0.128.0) — MIT
|
||||
|
||||
<details>
|
||||
@@ -87,7 +308,29 @@ THE SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore (2.2.5) — MIT
|
||||
### httpx (0.28.1) — BSD License
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/).
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore (2.3.2) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
@@ -505,7 +748,7 @@ THE SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### @michaelhart/meshcore-decoder (0.2.7) — MIT
|
||||
### @michaelhart/meshcore-decoder (0.3.0) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
@@ -722,7 +965,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### @uiw/react-codemirror (4.25.4) — MIT
|
||||
### @uiw/react-codemirror (4.25.8) — MIT
|
||||
|
||||
*License file not found in package.*
|
||||
|
||||
@@ -945,6 +1188,37 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
</details>
|
||||
|
||||
### cmdk (1.1.1) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Paco Coursey
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### d3-force (3.0.0) — ISC
|
||||
|
||||
<details>
|
||||
@@ -1084,7 +1358,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore-hashtag-cracker (1.7.0) — MIT
|
||||
### meshcore-hashtag-cracker (1.11.0) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
@@ -1349,6 +1623,70 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
</details>
|
||||
|
||||
### react-swipeable (7.0.2) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (C) 2014-2022 Josh Perez
|
||||
Copyright (C) 2014-2022 Brian Emil Hartz
|
||||
Copyright (C) 2022 Formidable Labs, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### recharts (3.8.1) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present recharts
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### sonner (2.0.7) — MIT
|
||||
|
||||
<details>
|
||||
@@ -1380,7 +1718,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### tailwind-merge (3.4.0) — MIT
|
||||
### tailwind-merge (3.5.0) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
@@ -7,24 +7,24 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Forward decrypted packets to MQTT brokers
|
||||
* Search for hashtag channel names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
|
||||
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
|
||||
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||

|
||||
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
|
||||
|
||||
## Disclaimer
|
||||

|
||||
|
||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||
|
||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||
> [!WARNING]
|
||||
> RemoteTerm does *full* management of the radio, meaning that once a radio is connected to RemoteTerm, all contacts/channels will be imported and offloaded to RemoteTerm and the contacts actually synced to the device will be governed by RemoteTerm. This means that RemoteTerm can be a poor fit for users who are looking to swap radios in and out, maintaining radio state (favorites, channels, etc.) irrespective of app usage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- Node.js LTS or current (20, 22, 24, 25) if you're not using a prebuilt release
|
||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||
|
||||
@@ -42,6 +42,12 @@ ls /dev/ttyUSB* /dev/ttyACM*
|
||||
#######
|
||||
ls /dev/cu.usbserial-* /dev/cu.usbmodem*
|
||||
|
||||
###########
|
||||
# Windows
|
||||
###########
|
||||
# In PowerShell:
|
||||
Get-CimInstance Win32_SerialPort | Select-Object DeviceID, Caption
|
||||
|
||||
######
|
||||
# WSL2
|
||||
######
|
||||
@@ -50,12 +56,15 @@ winget install usbipd
|
||||
# restart console
|
||||
# then find device ID
|
||||
usbipd list
|
||||
# attach device to WSL
|
||||
# make device shareable
|
||||
usbipd bind --busid 3-8 # (or whatever the right ID is)
|
||||
# attach device to WSL (run this each time you plug in the device)
|
||||
usbipd attach --wsl --busid 3-8
|
||||
# device will appear in WSL as /dev/ttyUSB0 or /dev/ttyACM0
|
||||
```
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
## Install Path 1: Clone And Build
|
||||
|
||||
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
|
||||
|
||||
@@ -63,252 +72,143 @@ usbipd bind --busid 3-8 # (or whatever the right ID is)
|
||||
git clone https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
|
||||
cd Remote-Terminal-for-MeshCore
|
||||
|
||||
# Install backend dependencies
|
||||
uv sync
|
||||
|
||||
# Build frontend
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
|
||||
# Run server
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The server auto-detects the serial port. To specify a transport manually:
|
||||
```bash
|
||||
# Serial (explicit port)
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Access the app at http://localhost:8000.
|
||||
|
||||
# TCP (e.g. via wifi-enabled firmware)
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
# BLE (address and PIN both required)
|
||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
> [!TIP]
|
||||
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||
|
||||
Access at http://localhost:8000
|
||||
> [!NOTE]
|
||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||
>
|
||||
> ```bash
|
||||
> bash scripts/setup/install_service.sh
|
||||
> ```
|
||||
>
|
||||
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||
|
||||
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
|
||||
## Install Path 2: Docker
|
||||
|
||||
## Docker Compose
|
||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform.
|
||||
|
||||
> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
|
||||
For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct.
|
||||
|
||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
||||
Create a local `docker-compose.yml` in one of two ways:
|
||||
|
||||
1. Copy the example file and edit it by hand:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
```
|
||||
|
||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
|
||||
2. Or generate one interactively:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
bash scripts/setup/install_docker.sh
|
||||
```
|
||||
|
||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
||||
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
||||
|
||||
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||
|
||||
Then customize the local compose file for your transport and launch:
|
||||
|
||||
```bash
|
||||
sudo docker compose up # add -d for background once you validate it's working
|
||||
```
|
||||
|
||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
|
||||
|
||||
To rebuild after pulling updates:
|
||||
|
||||
```bash
|
||||
sudo docker compose pull
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
|
||||
|
||||
```yaml
|
||||
build: .
|
||||
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
image: jkingsman/remoteterm-meshcore:latest
|
||||
build: .
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user.
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
sudo docker compose down
|
||||
```
|
||||
|
||||
## Development
|
||||
## Standard Environment Variables
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run uvicorn app.main:app --reload # autodetects serial port
|
||||
|
||||
# Or with explicit serial port
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
|
||||
npm run build # Production build to dist/
|
||||
```
|
||||
|
||||
Run both the backend and `npm run dev` for hot-reloading frontend development.
|
||||
|
||||
### Code Quality & Tests
|
||||
|
||||
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend.
|
||||
|
||||
Run everything at once (parallelized):
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Or run individual checks</summary>
|
||||
|
||||
```bash
|
||||
# python
|
||||
uv run ruff check app/ tests/ --fix # lint + auto-fix
|
||||
uv run ruff format app/ tests/ # format (always writes)
|
||||
uv run pyright app/ # type checking
|
||||
PYTHONPATH=. uv run pytest tests/ -v # backend tests
|
||||
|
||||
# frontend
|
||||
cd frontend
|
||||
npm run lint:fix # esLint + auto-fix
|
||||
npm run test:run # run tests
|
||||
npm run format # prettier (always writes)
|
||||
npm run build # build the frontend
|
||||
```
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
||||
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
|
||||
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
|
||||
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
|
||||
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database path |
|
||||
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config; an intermediate security precaution, but not as good as basic auth) |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
|
||||
## Additional Setup
|
||||
|
||||
<details>
|
||||
<summary>HTTPS (Required for WebGPU room-finding outside localhost)</summary>
|
||||
|
||||
WebGPU requires a secure context. When not on `localhost`, serve over HTTPS:
|
||||
Common launch patterns:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
|
||||
# Serial (explicit port)
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# TCP
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# BLE
|
||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
For Docker Compose, generate the cert and add the volume mounts and command override to `docker-compose.yaml`:
|
||||
On Windows (PowerShell), set environment variables as a separate statement:
|
||||
|
||||
```bash
|
||||
# generate snakeoil TLS cert
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
|
||||
```powershell
|
||||
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Then add the key and cert to the `remoteterm` service in `docker-compose.yaml`, and add an explicit launch command that uses them:
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./cert.pem:/app/cert.pem:ro
|
||||
- ./key.pem:/app/key.pem:ro
|
||||
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
|
||||
```
|
||||
## Where To Go Next
|
||||
|
||||
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
|
||||
</details>
|
||||
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||
|
||||
<details>
|
||||
<summary>Systemd Service (Linux)</summary>
|
||||
## Disclaimer
|
||||
|
||||
Assumes you're running from `/opt/remoteterm`; update commands and `remoteterm.service` if you're running elsewhere.
|
||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||
|
||||
```bash
|
||||
# Create service user
|
||||
sudo useradd -r -m -s /bin/false remoteterm
|
||||
|
||||
# Install to /opt/remoteterm
|
||||
sudo mkdir -p /opt/remoteterm
|
||||
sudo cp -r . /opt/remoteterm/
|
||||
sudo chown -R remoteterm:remoteterm /opt/remoteterm
|
||||
|
||||
# Install dependencies
|
||||
cd /opt/remoteterm
|
||||
sudo -u remoteterm uv venv
|
||||
sudo -u remoteterm uv sync
|
||||
|
||||
# Build frontend (required for the backend to serve the web UI)
|
||||
cd /opt/remoteterm/frontend
|
||||
sudo -u remoteterm npm install
|
||||
sudo -u remoteterm npm run build
|
||||
|
||||
# Install and start service
|
||||
sudo cp /opt/remoteterm/remoteterm.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now remoteterm
|
||||
|
||||
# Check status
|
||||
sudo systemctl status remoteterm
|
||||
sudo journalctl -u remoteterm -f
|
||||
```
|
||||
|
||||
Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if needed.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Testing</summary>
|
||||
|
||||
**Backend:**
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
**E2E:**
|
||||
|
||||
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly. E2E tests are generally not necessary to run for normal development work.
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npx playwright test # headless
|
||||
npx playwright test --headed # show the browser window
|
||||
```
|
||||
</details>
|
||||
|
||||
## API Documentation
|
||||
|
||||
With the backend running: http://localhost:8000/docs
|
||||
|
||||
## Debugging & Bug Reports
|
||||
|
||||
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
||||
|
||||
To start the server with debug logging:
|
||||
|
||||
```bash
|
||||
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Please include the relevant debug log output when filing an issue on GitHub.
|
||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Advanced Setup And Troubleshooting
|
||||
|
||||
## Remediation Environment Variables
|
||||
|
||||
These are intended for diagnosing or working around radios that behave oddly.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
||||
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||
|
||||
- whether messages were left on the radio without reaching the app through event subscription
|
||||
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
||||
|
||||
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
||||
|
||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||
|
||||
## Sub-Path Reverse Proxy
|
||||
|
||||
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing.
|
||||
- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution.
|
||||
|
||||
## HTTPS
|
||||
|
||||
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||
|
||||
Generate a local cert and start the backend with TLS:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
|
||||
```
|
||||
|
||||
For Docker Compose, generate the cert, mount it into the container, and override the launch command:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
remoteterm:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./cert.pem:/app/cert.pem:ro
|
||||
- ./key.pem:/app/key.pem:ro
|
||||
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
|
||||
```
|
||||
|
||||
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
|
||||
|
||||
## Systemd Service
|
||||
|
||||
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
||||
|
||||
```bash
|
||||
bash scripts/setup/install_service.sh
|
||||
```
|
||||
|
||||
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
||||
|
||||
## Debug Logging And Bug Reports
|
||||
|
||||
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
||||
|
||||
```bash
|
||||
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
You can also navigate to `/api/debug` (or go to Settings -> About -> "Open debug support snapshot" at the bottom). This debug block contains information about the operating environment, expectations around keys and channels, and radio status. It also includes the most recent logs. **Non-log information reveals no keys, channel names, or other privilege information beyond the names of your bots. The logs, however, may contain channel names or keys (but never your private key).** If you do not wish to include this information, copy up to the `STOP COPYING HERE` marker in the debug body.
|
||||
|
||||
## Development Notes
|
||||
|
||||
For day-to-day development, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
Windows note: I've seen an intermittent startup issue like `"Received empty packet: index out of range"` with failed contact sync. I can't figure out why this happens. The issue typically resolves on restart. If you can figure out why this happens, I will buy you a virtual or iRL six pack if you're in the PNW. As a former always-windows-girlie before embracing WSL2, I despise second-classing M$FT users, but I'm just stuck with this one.
|
||||
+160
-70
@@ -11,38 +11,64 @@ Keep it aligned with `app/` source files and router behavior.
|
||||
- MeshCore Python library (`meshcore` from PyPI)
|
||||
- PyCryptodome
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer strong domain modules over layers of pass-through helpers.
|
||||
- Split code when the new module owns real policy, not just a nicer name.
|
||||
- Avoid wrapper services around globals unless they materially improve testability or reduce coupling.
|
||||
- Keep workflows locally understandable; do not scatter one reasoning unit across several files without a clear contract.
|
||||
- Typed write/read contracts are preferred over loose dict-shaped repository inputs.
|
||||
|
||||
## Backend Map
|
||||
|
||||
```text
|
||||
app/
|
||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── channel_constants.py # Public/default channel constants shared across sync/send logic
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations.py # Schema migrations (SQLite user_version)
|
||||
├── models.py # Pydantic request/response models
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings)
|
||||
├── radio.py # RadioManager + auto-reconnect monitor
|
||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
├── services/ # Shared orchestration/domain services
|
||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
||||
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
|
||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
|
||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||
├── radio.py # RadioManager transport/session state + lock management
|
||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||
├── decoder.py # Packet parsing/decryption
|
||||
├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
|
||||
├── events.py # Typed WS event payload serialization
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
|
||||
├── mqtt.py # Private MQTT publisher (fire-and-forget forwarding)
|
||||
├── community_mqtt.py # Community MQTT publisher (raw packet sharing)
|
||||
├── bot.py # Bot execution and outbound bot sends
|
||||
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── region_scope.py # Normalize/validate regional flood-scope values
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
├── health.py
|
||||
├── debug.py
|
||||
├── radio.py
|
||||
├── contacts.py
|
||||
├── channels.py
|
||||
├── messages.py
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── rooms.py
|
||||
├── server_control.py
|
||||
├── settings.py
|
||||
├── fanout.py
|
||||
├── repeaters.py
|
||||
├── statistics.py
|
||||
└── ws.py
|
||||
@@ -54,35 +80,63 @@ app/
|
||||
|
||||
1. Radio emits events.
|
||||
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
|
||||
3. Decrypted messages are inserted into `messages` and broadcast over WS.
|
||||
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
|
||||
3. Shared message-domain services create/update `messages` and shape WS payloads.
|
||||
4. Direct-message storage is centralized in `services/dm_ingest.py`; packet-processor DMs and `CONTACT_MSG_RECV` fallback events both route through that seam.
|
||||
|
||||
### Outgoing messages
|
||||
|
||||
1. Send endpoints in `routers/messages.py` call MeshCore commands.
|
||||
2. Message is persisted as outgoing.
|
||||
1. Send endpoints in `routers/messages.py` validate requests and delegate to `services/message_send.py`.
|
||||
2. Service-layer send workflows call MeshCore commands, persist outgoing messages, and wire ACK tracking.
|
||||
3. Endpoint broadcasts WS `message` event so all live clients update.
|
||||
4. ACK/repeat updates arrive later as `message_acked` events.
|
||||
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend — a safe assumption since name changes require a radio config update and are not something that happens mid-conversation.
|
||||
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend. Name changes require an explicit radio config update and are rare, but the `new_timestamp=true` resend path has no time window, so a mismatch is possible if the name was changed between the original send and a later resend.
|
||||
|
||||
### Connection lifecycle
|
||||
|
||||
- `RadioManager.start_connection_monitor()` checks health every 5s.
|
||||
- Monitor reconnect path runs `post_connect_setup()` before broadcasting healthy state.
|
||||
- Manual reconnect/reboot endpoints call `reconnect()` then `post_connect_setup()`.
|
||||
- Setup includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
|
||||
- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`.
|
||||
- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly.
|
||||
- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state.
|
||||
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling. That audit checks both missed-radio-message drift and channel-slot cache drift; cache mismatches are logged, toasted, and the send-slot cache is reset.
|
||||
- Post-connect setup is timeout-bounded. If initial radio offload/setup hangs too long, the backend logs the failure and broadcasts an `error` toast telling the operator to reboot the radio and restart the server.
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Multibyte routing
|
||||
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
|
||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
|
||||
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
|
||||
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
|
||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||
|
||||
### Read/unread state
|
||||
|
||||
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
|
||||
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
|
||||
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, and `last_read_ats`.
|
||||
|
||||
### DM ingest + ACKs
|
||||
|
||||
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
|
||||
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
|
||||
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
|
||||
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked.
|
||||
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
|
||||
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)` when the radio provides an expected ACK code: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
|
||||
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
|
||||
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
|
||||
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
|
||||
|
||||
### Echo/repeat dedup
|
||||
|
||||
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
|
||||
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented **only for outgoing messages**. Incoming repeats add path data but do not change the ACK count.
|
||||
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming direct messages with the same conversation/text/sender timestamp also collapse onto one stored row, with later observations merging path data instead of creating a second DM.
|
||||
|
||||
### Raw packet dedup policy
|
||||
|
||||
@@ -103,100 +157,102 @@ app/
|
||||
- `0` means disabled.
|
||||
- Last send time tracked in `app_settings.last_advert_time`.
|
||||
|
||||
### MQTT publishing
|
||||
### Fanout bus
|
||||
|
||||
- Optional forwarding of mesh events to an external MQTT broker.
|
||||
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
|
||||
- Disabled when `mqtt_broker_host` is empty, or when both publish toggles are off (`mqtt_publish_messages=false` and `mqtt_publish_raw_packets=false`).
|
||||
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
|
||||
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
|
||||
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
|
||||
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
|
||||
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`), where `disabled` covers both "no broker host configured" and "nothing enabled to publish".
|
||||
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
|
||||
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
|
||||
|
||||
### Community MQTT
|
||||
|
||||
- Separate publisher (`app/community_mqtt.py`) for sharing raw packets with the MeshCore community aggregator.
|
||||
- Implementation intent: keep functional parity with the reference implementation at `https://github.com/agessaman/meshcore-packet-capture` unless this repository explicitly documents a deliberate deviation.
|
||||
- Independent from the private `MqttPublisher` — different broker, authentication, and topic structure.
|
||||
- Connects to the community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
|
||||
- Authentication: Ed25519 JWT tokens signed with the radio's expanded "orlp" private key. Tokens expire after 24 hours; proactive renewal at 23 hours.
|
||||
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
|
||||
- JWT claims include `publicKey`, `owner` (radio pubkey), `client` (app identifier), and optional `email` (for node claiming on the community aggregator).
|
||||
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code (required to enable; no default).
|
||||
- Only raw packets are published — never decrypted messages.
|
||||
- Publishes are fire-and-forget. The connection loop detects publish failures via `connected` flag and reconnects within 60 seconds.
|
||||
- Health endpoint includes `community_mqtt_status` field.
|
||||
- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`.
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise, SQS) are managed through the fanout bus (`app/fanout/`).
|
||||
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
|
||||
- Each integration is a `FanoutModule` with scope-based filtering.
|
||||
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
|
||||
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
|
||||
|
||||
## API Surface (all under `/api`)
|
||||
|
||||
### Health
|
||||
- `GET /health`
|
||||
|
||||
### Debug
|
||||
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
|
||||
|
||||
### Radio
|
||||
- `GET /radio/config`
|
||||
- `PATCH /radio/config`
|
||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
|
||||
- `POST /radio/disconnect`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
|
||||
### Contacts
|
||||
- `GET /contacts`
|
||||
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||
- `GET /contacts/{public_key}`
|
||||
- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters)
|
||||
- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact
|
||||
- `POST /contacts`
|
||||
- `POST /contacts/bulk-delete`
|
||||
- `DELETE /contacts/{public_key}`
|
||||
- `POST /contacts/sync`
|
||||
- `POST /contacts/{public_key}/add-to-radio`
|
||||
- `POST /contacts/{public_key}/remove-from-radio`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/reset-path`
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
|
||||
- `POST /contacts/{public_key}/repeater/login`
|
||||
- `POST /contacts/{public_key}/repeater/status`
|
||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/repeater/neighbors`
|
||||
- `POST /contacts/{public_key}/repeater/acl`
|
||||
- `POST /contacts/{public_key}/repeater/node-info`
|
||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
- `POST /contacts/{public_key}/room/login`
|
||||
- `POST /contacts/{public_key}/room/status`
|
||||
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/room/acl`
|
||||
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}`
|
||||
- `GET /channels/{key}/detail`
|
||||
- `POST /channels`
|
||||
- `POST /channels/bulk-hashtag`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/path-hash-mode-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
- `GET /messages`
|
||||
- `GET /messages` — list with filters; supports `q` (full-text search), `after`/`after_id` (forward cursor)
|
||||
- `GET /messages/around/{message_id}` — context messages around a target (for jump-to-message navigation)
|
||||
- `POST /messages/direct`
|
||||
- `POST /messages/channel`
|
||||
- `POST /messages/channel/{message_id}/resend`
|
||||
|
||||
### Packets
|
||||
- `GET /packets/undecrypted/count`
|
||||
- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection
|
||||
- `POST /packets/decrypt/historical`
|
||||
- `POST /packets/maintenance`
|
||||
|
||||
### Read state
|
||||
- `GET /read-state/unreads`
|
||||
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, and `last_read_ats`
|
||||
- `POST /read-state/mark-all-read`
|
||||
|
||||
### Settings
|
||||
- `GET /settings`
|
||||
- `PATCH /settings`
|
||||
- `POST /settings/favorites/toggle`
|
||||
- `POST /settings/blocked-keys/toggle`
|
||||
- `POST /settings/blocked-names/toggle`
|
||||
- `POST /settings/migrate`
|
||||
|
||||
### Fanout
|
||||
- `GET /fanout` — list all fanout configs
|
||||
- `POST /fanout` — create new fanout config
|
||||
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
||||
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
||||
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
|
||||
@@ -207,43 +263,58 @@ app/
|
||||
|
||||
- `health` — radio connection status (broadcast on change, personal on connect)
|
||||
- `contact` — single contact upsert (from advertisements and radio sync)
|
||||
- `contact_resolved` — prefix contact reconciled to a full contact row (payload: `{ previous_public_key, contact }`)
|
||||
- `message` — new message (channel or DM, from packet processor or send endpoints)
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
- `error` — toast notification (reconnect failure, missing private key, etc.)
|
||||
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
|
||||
- `channel` — single channel upsert/update (payload: full `Channel`)
|
||||
- `channel_deleted` — channel removed from database (payload: `{ key }`)
|
||||
- `error` — toast notification (reconnect failure, missing private key, stuck radio startup, etc.)
|
||||
- `success` — toast notification (historical decrypt complete, etc.)
|
||||
|
||||
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
Backend WS sends go through typed serialization in `events.py`. Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
|
||||
## Data Model Notes
|
||||
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking)
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact)
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `app_settings`
|
||||
|
||||
Contact route state is canonicalized on the backend:
|
||||
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
|
||||
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
|
||||
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
|
||||
|
||||
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
|
||||
|
||||
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
|
||||
|
||||
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
|
||||
|
||||
`app_settings` fields in active model:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
|
||||
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
- No authn/authz.
|
||||
- No per-user authn/authz model; optionally, operators may enable app-wide HTTP Basic auth for both HTTP and WS entrypoints.
|
||||
- No CORS restriction (`*`).
|
||||
- Bot code executes user-provided Python via `exec()`.
|
||||
|
||||
@@ -264,34 +335,53 @@ tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_config.py # Configuration validation
|
||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||
├── test_contacts_router.py # Contacts router endpoints
|
||||
├── test_decoder.py # Packet parsing/decryption
|
||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||
├── test_frontend_static.py # Frontend static file serving
|
||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||
├── test_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_map_upload.py # Map upload fanout module
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_radio_sync.py # Polling, sync, advertisement
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||
├── test_repository.py # Data access layer
|
||||
├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_version_info.py # Version/build metadata resolution
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
```
|
||||
@@ -302,7 +392,7 @@ tests/
|
||||
|
||||
The MeshCore radio protocol encodes `sender_timestamp` as a 4-byte little-endian integer (Unix seconds). This is a firmware-level wire format — the radio, the Python library (`commands/messaging.py`), and the decoder (`decoder.py`) all read/write exactly 4 bytes. Millisecond Unix timestamps would overflow 4 bytes, so higher resolution is not possible without a firmware change.
|
||||
|
||||
**Consequence:** The dedup index `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` operates at 1-second granularity. Sending identical text to the same conversation twice within one second will hit the UNIQUE constraint on the second insert, returning HTTP 500 *after* the radio has already transmitted. The message is sent over the air but not stored in the database. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`.
|
||||
**Consequence:** Message dedup still operates at 1-second granularity because the radio protocol only provides second-resolution `sender_timestamp`. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`. Incoming DMs now share the same second-resolution content identity tradeoff as channel echoes: same-contact same-text same-second observations collapse onto one stored row.
|
||||
|
||||
### Outgoing DM echoes remain undecrypted
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
PUBLIC_CHANNEL_KEY = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
|
||||
PUBLIC_CHANNEL_NAME = "Public"
|
||||
|
||||
|
||||
def is_public_channel_key(key: str) -> bool:
|
||||
return key.upper() == PUBLIC_CHANNEL_KEY
|
||||
|
||||
|
||||
def is_public_channel_name(name: str) -> bool:
|
||||
return name.casefold() == PUBLIC_CHANNEL_NAME.casefold()
|
||||
@@ -1,402 +0,0 @@
|
||||
"""Community MQTT publisher for sharing raw packets with the MeshCore community.
|
||||
|
||||
Publishes raw packet data to mqtt-us-v1.letsmesh.net using the protocol
|
||||
defined by meshcore-packet-capture (https://github.com/agessaman/meshcore-packet-capture).
|
||||
|
||||
Authentication uses Ed25519 JWT tokens signed with the radio's private key.
|
||||
This module is independent from the private MqttPublisher in app/mqtt.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import nacl.bindings
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.mqtt_base import BaseMqttPublisher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
|
||||
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
|
||||
_CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)"
|
||||
|
||||
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
|
||||
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
|
||||
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
||||
|
||||
# Ed25519 group order
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
|
||||
# Route type mapping: bottom 2 bits of first byte
|
||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||
|
||||
|
||||
def _base64url_encode(data: bytes) -> str:
|
||||
"""Base64url encode without padding."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _ed25519_sign_expanded(
|
||||
message: bytes, scalar: bytes, prefix: bytes, public_key: bytes
|
||||
) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32).
|
||||
Standard Ed25519 libraries expect seed format and would re-SHA-512 the key.
|
||||
This performs the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
# r = SHA-512(prefix || message) mod L
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
# R = r * B (base point multiplication)
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
# k = SHA-512(R || public_key || message) mod L
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
# s = (r + k * scalar) mod L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def _generate_jwt_token(
|
||||
private_key: bytes,
|
||||
public_key: bytes,
|
||||
*,
|
||||
audience: str = _DEFAULT_BROKER,
|
||||
email: str = "",
|
||||
) -> str:
|
||||
"""Generate a JWT token for community MQTT authentication.
|
||||
|
||||
Creates a token with Ed25519 signature using MeshCore's expanded key format.
|
||||
Token format: header_b64.payload_b64.signature_hex
|
||||
|
||||
Optional ``email`` embeds a node-claiming identity so the community
|
||||
aggregator can associate this radio with an owner.
|
||||
"""
|
||||
header = {"alg": "Ed25519", "typ": "JWT"}
|
||||
now = int(time.time())
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
payload: dict[str, object] = {
|
||||
"publicKey": pubkey_hex,
|
||||
"iat": now,
|
||||
"exp": now + _TOKEN_LIFETIME,
|
||||
"aud": audience,
|
||||
"owner": pubkey_hex,
|
||||
"client": _CLIENT_ID,
|
||||
}
|
||||
if email:
|
||||
payload["email"] = email
|
||||
|
||||
header_b64 = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
|
||||
scalar = private_key[:32]
|
||||
prefix = private_key[32:]
|
||||
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
|
||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||
|
||||
|
||||
def _calculate_packet_hash(raw_bytes: bytes) -> str:
|
||||
"""Calculate packet hash matching MeshCore's Packet::calculatePacketHash().
|
||||
|
||||
Parses the packet structure to extract payload type and payload data,
|
||||
then hashes: payload_type(1 byte) [+ path_len(2 bytes LE) for TRACE] + payload_data.
|
||||
Returns first 16 hex characters (uppercase).
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return "0" * 16
|
||||
|
||||
try:
|
||||
header = raw_bytes[0]
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
route_type = header & 0x03
|
||||
|
||||
# Transport codes present for TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3)
|
||||
has_transport = route_type in (0x00, 0x03)
|
||||
|
||||
offset = 1 # Past header
|
||||
if has_transport:
|
||||
offset += 4 # Skip 4 bytes of transport codes
|
||||
|
||||
# Read path_len (1 byte on wire). Invalid/truncated packets map to zero hash.
|
||||
if offset >= len(raw_bytes):
|
||||
return "0" * 16
|
||||
path_len = raw_bytes[offset]
|
||||
offset += 1
|
||||
|
||||
# Skip past path to get to payload. Invalid/truncated packets map to zero hash.
|
||||
if len(raw_bytes) < offset + path_len:
|
||||
return "0" * 16
|
||||
payload_start = offset + path_len
|
||||
payload_data = raw_bytes[payload_start:]
|
||||
|
||||
# Hash: payload_type(1 byte) [+ path_len as uint16_t LE for TRACE] + payload_data
|
||||
hash_obj = hashlib.sha256()
|
||||
hash_obj.update(bytes([payload_type]))
|
||||
if payload_type == 9: # PAYLOAD_TYPE_TRACE
|
||||
hash_obj.update(path_len.to_bytes(2, byteorder="little"))
|
||||
hash_obj.update(payload_data)
|
||||
|
||||
return hash_obj.hexdigest()[:16].upper()
|
||||
except Exception:
|
||||
return "0" * 16
|
||||
|
||||
|
||||
def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], int | None]:
|
||||
"""Decode packet fields used by the community uploader payload format.
|
||||
|
||||
Returns:
|
||||
(route_letter, packet_type_str, payload_len_str, path_values, payload_type_int)
|
||||
"""
|
||||
# Reference defaults when decode fails
|
||||
route = "U"
|
||||
packet_type = "0"
|
||||
payload_len = "0"
|
||||
path_values: list[str] = []
|
||||
payload_type: int | None = None
|
||||
|
||||
try:
|
||||
if len(raw_bytes) < 2:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
header = raw_bytes[0]
|
||||
payload_version = (header >> 6) & 0x03
|
||||
if payload_version != 0:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
route_type = header & 0x03
|
||||
has_transport = route_type in (0x00, 0x03)
|
||||
|
||||
offset = 1
|
||||
if has_transport:
|
||||
offset += 4
|
||||
|
||||
if len(raw_bytes) <= offset:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
path_len = raw_bytes[offset]
|
||||
offset += 1
|
||||
|
||||
if len(raw_bytes) < offset + path_len:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
path_bytes = raw_bytes[offset : offset + path_len]
|
||||
offset += path_len
|
||||
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
route = _ROUTE_MAP.get(route_type, "U")
|
||||
packet_type = str(payload_type)
|
||||
payload_len = str(max(0, len(raw_bytes) - offset))
|
||||
path_values = [f"{b:02x}" for b in path_bytes]
|
||||
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
except Exception:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
|
||||
def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: str) -> dict:
|
||||
"""Convert a RawPacketBroadcast dict to meshcore-packet-capture format."""
|
||||
raw_hex = data.get("data", "")
|
||||
raw_bytes = bytes.fromhex(raw_hex) if raw_hex else b""
|
||||
|
||||
route, packet_type, payload_len, path_values, _payload_type = _decode_packet_fields(raw_bytes)
|
||||
|
||||
# Reference format uses local "now" timestamp and derived time/date fields.
|
||||
current_time = datetime.now()
|
||||
ts_str = current_time.isoformat()
|
||||
|
||||
# SNR/RSSI are always strings in reference output.
|
||||
snr_val = data.get("snr")
|
||||
rssi_val = data.get("rssi")
|
||||
snr = str(snr_val) if snr_val is not None else "Unknown"
|
||||
rssi = str(rssi_val) if rssi_val is not None else "Unknown"
|
||||
|
||||
packet_hash = _calculate_packet_hash(raw_bytes)
|
||||
|
||||
packet = {
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": public_key_hex.upper(),
|
||||
"timestamp": ts_str,
|
||||
"type": "PACKET",
|
||||
"direction": "rx",
|
||||
"time": current_time.strftime("%H:%M:%S"),
|
||||
"date": current_time.strftime("%d/%m/%Y"),
|
||||
"len": str(len(raw_bytes)),
|
||||
"packet_type": packet_type,
|
||||
"route": route,
|
||||
"payload_len": payload_len,
|
||||
"raw": raw_hex.upper(),
|
||||
"SNR": snr,
|
||||
"RSSI": rssi,
|
||||
"hash": packet_hash,
|
||||
}
|
||||
|
||||
if route == "D":
|
||||
packet["path"] = ",".join(path_values)
|
||||
|
||||
return packet
|
||||
|
||||
|
||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
"""Manages the community MQTT connection and publishes raw packets."""
|
||||
|
||||
_backoff_max = 60
|
||||
_log_prefix = "Community MQTT"
|
||||
_not_configured_timeout: float | None = 30
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._key_unavailable_warned: bool = False
|
||||
|
||||
async def start(self, settings: AppSettings) -> None:
|
||||
self._key_unavailable_warned = False
|
||||
await super().start(settings)
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
from app.keystore import has_private_key
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
if (
|
||||
self._settings
|
||||
and self._settings.community_mqtt_enabled
|
||||
and not has_private_key()
|
||||
and not self._key_unavailable_warned
|
||||
):
|
||||
broadcast_error(
|
||||
"Community MQTT unavailable",
|
||||
"Radio firmware does not support private key export.",
|
||||
)
|
||||
self._key_unavailable_warned = True
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if community MQTT is enabled and keys are available."""
|
||||
from app.keystore import has_private_key
|
||||
|
||||
return bool(self._settings and self._settings.community_mqtt_enabled and has_private_key())
|
||||
|
||||
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
assert private_key is not None and public_key is not None # guaranteed by _pre_connect
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
jwt_token = _generate_jwt_token(
|
||||
private_key,
|
||||
public_key,
|
||||
audience=broker_host,
|
||||
email=settings.community_mqtt_email or "",
|
||||
)
|
||||
|
||||
tls_context = ssl.create_default_context()
|
||||
|
||||
return {
|
||||
"hostname": broker_host,
|
||||
"port": broker_port,
|
||||
"transport": "websockets",
|
||||
"tls_context": tls_context,
|
||||
"websocket_path": "/",
|
||||
"username": f"v1_{pubkey_hex}",
|
||||
"password": jwt_token,
|
||||
}
|
||||
|
||||
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
|
||||
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return (
|
||||
"Community MQTT connection failure",
|
||||
"Check your internet connection or try again later.",
|
||||
)
|
||||
|
||||
def _should_break_wait(self, elapsed: float) -> bool:
|
||||
if not self.connected:
|
||||
logger.info("Community MQTT publish failure detected, reconnecting")
|
||||
return True
|
||||
if elapsed >= _TOKEN_RENEWAL_THRESHOLD:
|
||||
logger.info("Community MQTT JWT nearing expiry, reconnecting")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _pre_connect(self, settings: AppSettings) -> bool:
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
if private_key is None or public_key is None:
|
||||
# Keys not available yet, wait for settings change or key export
|
||||
self.connected = False
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
community_publisher = CommunityMqttPublisher()
|
||||
|
||||
|
||||
def community_mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget community MQTT publish for raw packets only."""
|
||||
if event_type != "raw_packet":
|
||||
return
|
||||
if not community_publisher.connected or community_publisher._settings is None:
|
||||
return
|
||||
asyncio.create_task(_community_maybe_publish(data))
|
||||
|
||||
|
||||
async def _community_maybe_publish(data: dict[str, Any]) -> None:
|
||||
"""Format and publish a raw packet to the community broker."""
|
||||
settings = community_publisher._settings
|
||||
if settings is None or not settings.community_mqtt_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
return
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
|
||||
# Get device name from radio
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
packet = _format_raw_packet(data, device_name, pubkey_hex)
|
||||
iata = settings.community_mqtt_iata.upper().strip()
|
||||
if not _IATA_RE.fullmatch(iata):
|
||||
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
|
||||
return
|
||||
topic = f"meshcore/{iata}/{pubkey_hex}/packets"
|
||||
|
||||
await community_publisher.publish(topic, packet)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Community MQTT broadcast error: %s", e)
|
||||
+168
-6
@@ -1,7 +1,10 @@
|
||||
import logging
|
||||
import logging.config
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -11,11 +14,21 @@ class Settings(BaseSettings):
|
||||
serial_port: str = "" # Empty string triggers auto-detection
|
||||
serial_baudrate: int = 115200
|
||||
tcp_host: str = ""
|
||||
tcp_port: int = 4000
|
||||
tcp_port: int = 5000
|
||||
ble_address: str = ""
|
||||
ble_pin: str = ""
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
database_path: str = "data/meshcore.db"
|
||||
disable_bots: bool = False
|
||||
enable_message_poll_fallback: bool = False
|
||||
force_channel_slot_reconfigure: bool = False
|
||||
clowntown_do_clock_wraparound: bool = Field(
|
||||
default=False,
|
||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||
)
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
@@ -33,6 +46,11 @@ class Settings(BaseSettings):
|
||||
)
|
||||
if self.ble_address and not self.ble_pin:
|
||||
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
|
||||
if self.basic_auth_partially_configured:
|
||||
raise ValueError(
|
||||
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
|
||||
"must be set together."
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -43,14 +61,158 @@ class Settings(BaseSettings):
|
||||
return "ble"
|
||||
return "serial"
|
||||
|
||||
@property
|
||||
def basic_auth_enabled(self) -> bool:
|
||||
return bool(self.basic_auth_username and self.basic_auth_password)
|
||||
|
||||
@property
|
||||
def basic_auth_partially_configured(self) -> bool:
|
||||
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
|
||||
return any_credentials_set and not self.basic_auth_enabled
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class _RingBufferLogHandler(logging.Handler):
|
||||
"""Keep a bounded in-memory tail of formatted log lines."""
|
||||
|
||||
def __init__(self, max_lines: int = 1000) -> None:
|
||||
super().__init__()
|
||||
self._buffer: deque[str] = deque(maxlen=max_lines)
|
||||
self._lock = Lock()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
line = self.format(record)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
return
|
||||
with self._lock:
|
||||
self._buffer.append(line)
|
||||
|
||||
def get_lines(self, limit: int = 1000) -> list[str]:
|
||||
with self._lock:
|
||||
if limit <= 0:
|
||||
return []
|
||||
return list(self._buffer)[-limit:]
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._buffer.clear()
|
||||
|
||||
|
||||
_recent_log_handler = _RingBufferLogHandler(max_lines=1000)
|
||||
|
||||
|
||||
def get_recent_log_lines(limit: int = 1000) -> list[str]:
|
||||
"""Return recent formatted log lines from the in-memory ring buffer."""
|
||||
return _recent_log_handler.get_lines(limit)
|
||||
|
||||
|
||||
def clear_recent_log_lines() -> None:
|
||||
"""Clear the in-memory log ring buffer."""
|
||||
_recent_log_handler.clear()
|
||||
|
||||
|
||||
class _RepeatSquelch(logging.Filter):
|
||||
"""Suppress rapid-fire identical messages and emit a summary instead.
|
||||
|
||||
Attached to the ``meshcore`` library logger to catch its repeated
|
||||
"Serial Connection started" lines that flood the log when another
|
||||
process holds the serial port.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: int = 3) -> None:
|
||||
super().__init__()
|
||||
self._last_msg: str | None = None
|
||||
self._repeat_count: int = 0
|
||||
self._threshold = threshold
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
if msg == self._last_msg:
|
||||
self._repeat_count += 1
|
||||
if self._repeat_count == self._threshold:
|
||||
record.msg = (
|
||||
"%s (repeated %d times — possible serial port contention from another process)"
|
||||
)
|
||||
record.args = (msg, self._repeat_count)
|
||||
record.levelno = logging.WARNING
|
||||
record.levelname = "WARNING"
|
||||
return True
|
||||
# Suppress further repeats beyond the threshold
|
||||
return self._repeat_count < self._threshold
|
||||
else:
|
||||
self._last_msg = msg
|
||||
self._repeat_count = 1
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure logging for the application."""
|
||||
logging.basicConfig(
|
||||
level=settings.log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
logging.config.dictConfig(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"uvicorn_access": {
|
||||
"()": "uvicorn.logging.AccessFormatter",
|
||||
"fmt": '%(asctime)s - %(name)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s',
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
"use_colors": None,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
},
|
||||
"uvicorn_access": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "uvicorn_access",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["uvicorn_access"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_recent_log_handler.setLevel(logging.DEBUG)
|
||||
_recent_log_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
for logger_name in ("", "uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
target = logging.getLogger(logger_name)
|
||||
if _recent_log_handler not in target.handlers:
|
||||
target.addHandler(_recent_log_handler)
|
||||
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
|
||||
|
||||
+86
-16
@@ -13,22 +13,33 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
direct_path TEXT,
|
||||
direct_path_len INTEGER,
|
||||
direct_path_hash_mode INTEGER,
|
||||
direct_path_updated_at INTEGER,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT,
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -38,17 +49,16 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
text TEXT NOT NULL,
|
||||
sender_timestamp INTEGER,
|
||||
received_at INTEGER NOT NULL,
|
||||
path TEXT,
|
||||
paths TEXT,
|
||||
txt_type INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
acked INTEGER DEFAULT 0,
|
||||
sender_name TEXT,
|
||||
sender_key TEXT
|
||||
-- Deduplication: identical text + timestamp in the same conversation is treated as a
|
||||
-- mesh echo/repeat. Second-precision timestamps mean two intentional identical messages
|
||||
-- within the same second would collide, but this is not feasible in practice — LoRa
|
||||
-- transmission takes several seconds per message, and the UI clears the input on send.
|
||||
-- Deduplication: channel echoes/repeats use a content/time unique index so
|
||||
-- duplicate observations reconcile onto a single stored row. Legacy
|
||||
-- databases may also gain an incoming-DM content index via migration 44.
|
||||
-- Enforced via idx_messages_dedup_null_safe (unique index) rather than a table constraint
|
||||
-- to avoid the storage overhead of SQLite's autoindex duplicating every message text.
|
||||
);
|
||||
@@ -59,7 +69,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
data BLOB NOT NULL,
|
||||
message_id INTEGER,
|
||||
payload_hash BLOB,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
@@ -70,8 +80,8 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
@@ -81,20 +91,70 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
max_radio_contacts INTEGER DEFAULT 200,
|
||||
favorites TEXT DEFAULT '[]',
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0,
|
||||
advert_interval INTEGER DEFAULT 0,
|
||||
last_advert_time INTEGER DEFAULT 0,
|
||||
flood_scope TEXT DEFAULT '',
|
||||
blocked_keys TEXT DEFAULT '[]',
|
||||
blocked_names TEXT DEFAULT '[]',
|
||||
discovery_blocked_types TEXT DEFAULT '[]',
|
||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||
auto_resend_channel INTEGER DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fanout_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
scope TEXT NOT NULL DEFAULT '{}',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0));
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||
WHERE type = 'CHAN';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||
WHERE type = 'PRIV' AND outgoing = 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_pagination
|
||||
ON messages(type, conversation_key, received_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
|
||||
ON messages(type, conversation_key, outgoing, received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||
ON messages(type, received_at, conversation_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||
ON contact_advert_paths(public_key, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
||||
ON contact_name_history(public_key, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||
ON repeater_telemetry_history(public_key, timestamp);
|
||||
"""
|
||||
|
||||
|
||||
@@ -119,6 +179,12 @@ class Database:
|
||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
|
||||
# Foreign key enforcement: must be set per-connection (not persisted).
|
||||
# Disabled during schema init and migrations to avoid issues with
|
||||
# historical table-rebuild migrations that may temporarily violate
|
||||
# constraints, then re-enabled for all subsequent application queries.
|
||||
await self._connection.execute("PRAGMA foreign_keys = OFF")
|
||||
|
||||
await self._connection.executescript(SCHEMA)
|
||||
await self._connection.commit()
|
||||
logger.debug("Database schema initialized")
|
||||
@@ -128,6 +194,10 @@ class Database:
|
||||
|
||||
await run_migrations(self._connection)
|
||||
|
||||
# Enable FK enforcement for all application queries from this point on.
|
||||
await self._connection.execute("PRAGMA foreign_keys = ON")
|
||||
logger.debug("Foreign key enforcement enabled")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
await self._connection.close()
|
||||
|
||||
+159
-81
@@ -58,6 +58,28 @@ class DecryptedDirectMessage:
|
||||
message: str
|
||||
dest_hash: str # First byte of destination pubkey as hex
|
||||
src_hash: str # First byte of sender pubkey as hex
|
||||
signed_sender_prefix: str | None = None
|
||||
|
||||
@property
|
||||
def txt_type(self) -> int:
|
||||
return self.flags >> 2
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
return self.flags & 0x03
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptedPathPayload:
|
||||
"""Result of decrypting a PATH payload."""
|
||||
|
||||
dest_hash: str
|
||||
src_hash: str
|
||||
returned_path: bytes
|
||||
returned_path_len: int
|
||||
returned_path_hash_mode: int
|
||||
extra_type: int
|
||||
extra: bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -79,18 +101,14 @@ class PacketInfo:
|
||||
route_type: RouteType
|
||||
payload_type: PayloadType
|
||||
payload_version: int
|
||||
path_length: int
|
||||
path: bytes # The routing path (empty if path_length is 0)
|
||||
path_length: int # Decoded hop count (not the raw wire byte)
|
||||
path: bytes # The routing path bytes (empty if path_length is 0)
|
||||
payload: bytes
|
||||
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
|
||||
|
||||
|
||||
def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
"""
|
||||
Calculate the channel hash from a 16-byte channel key.
|
||||
Returns the first byte of SHA256(key) as hex.
|
||||
"""
|
||||
hash_bytes = hashlib.sha256(channel_key).digest()
|
||||
return format(hash_bytes[0], "02x")
|
||||
def _is_valid_advert_location(lat: float, lon: float) -> bool:
|
||||
return -90 <= lat <= 90 and -180 <= lon <= 180
|
||||
|
||||
|
||||
def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
@@ -100,86 +118,36 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
Packet structure:
|
||||
- Byte 0: header (route_type, payload_type, version)
|
||||
- For TRANSPORT routes: bytes 1-4 are transport codes
|
||||
- Next byte: path_length
|
||||
- Next path_length bytes: path data
|
||||
- Next byte: path byte (packed as [hash_mode:2][hop_count:6])
|
||||
- Next hop_count * hash_size bytes: path data
|
||||
- Remaining: payload
|
||||
|
||||
Returns the payload bytes, or None if packet is malformed.
|
||||
"""
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Skip path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
offset += path_length
|
||||
|
||||
# Rest is payload
|
||||
return raw_packet[offset:]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
return envelope.payload if envelope is not None else None
|
||||
|
||||
|
||||
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
|
||||
"""Parse a raw packet and extract basic info."""
|
||||
if len(raw_packet) < 2:
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
if envelope is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = RouteType(header & 0x03)
|
||||
payload_type = PayloadType((header >> 2) & 0x0F)
|
||||
payload_version = (header >> 6) & 0x03
|
||||
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present
|
||||
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Extract path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
path = raw_packet[offset : offset + path_length]
|
||||
offset += path_length
|
||||
|
||||
# Rest is payload
|
||||
payload = raw_packet[offset:]
|
||||
|
||||
return PacketInfo(
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
payload_version=payload_version,
|
||||
path_length=path_length,
|
||||
path=path,
|
||||
payload=payload,
|
||||
route_type=RouteType(envelope.route_type),
|
||||
payload_type=PayloadType(envelope.payload_type),
|
||||
payload_version=envelope.payload_version,
|
||||
path_length=envelope.hop_count,
|
||||
path_hash_size=envelope.hash_size,
|
||||
path=envelope.path,
|
||||
payload=envelope.payload,
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -282,7 +250,7 @@ def try_decrypt_packet_with_channel_key(
|
||||
return None
|
||||
|
||||
packet_channel_hash = format(packet_info.payload[0], "02x")
|
||||
expected_hash = calculate_channel_hash(channel_key)
|
||||
expected_hash = format(hashlib.sha256(channel_key).digest()[0], "02x")
|
||||
|
||||
if packet_channel_hash != expected_hash:
|
||||
return None
|
||||
@@ -301,7 +269,9 @@ def get_packet_payload_type(raw_packet: bytes) -> PayloadType | None:
|
||||
return None
|
||||
|
||||
|
||||
def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
|
||||
def parse_advertisement(
|
||||
payload: bytes, raw_packet: bytes | None = None
|
||||
) -> ParsedAdvertisement | None:
|
||||
"""
|
||||
Parse an advertisement payload.
|
||||
|
||||
@@ -329,8 +299,11 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
|
||||
timestamp = int.from_bytes(payload[32:36], byteorder="little")
|
||||
flags = payload[100]
|
||||
|
||||
# Parse flags
|
||||
# Parse flags — clamp device_role to valid range (0-4); corrupted
|
||||
# advertisements can have junk in the lower nibble.
|
||||
device_role = flags & 0x0F
|
||||
if device_role > 4:
|
||||
device_role = 0
|
||||
has_location = bool(flags & 0x10)
|
||||
has_feature1 = bool(flags & 0x20)
|
||||
has_feature2 = bool(flags & 0x40)
|
||||
@@ -357,6 +330,16 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
|
||||
lon_raw = int.from_bytes(payload[offset + 4 : offset + 8], byteorder="little", signed=True)
|
||||
lat = lat_raw / 1_000_000
|
||||
lon = lon_raw / 1_000_000
|
||||
if not _is_valid_advert_location(lat, lon):
|
||||
packet_hex = (raw_packet if raw_packet is not None else payload).hex().upper()
|
||||
logger.warning(
|
||||
"Dropping location data for nonsensical packet -- packet %s implies lat/lon %s/%s. Outta this world!",
|
||||
packet_hex,
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
lat = None
|
||||
lon = None
|
||||
offset += 8
|
||||
|
||||
# Skip feature fields if present
|
||||
@@ -527,10 +510,19 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
|
||||
# Extract message text (UTF-8, null-padded)
|
||||
message_bytes = decrypted[5:]
|
||||
signed_sender_prefix: str | None = None
|
||||
txt_type = flags >> 2
|
||||
if txt_type == 2:
|
||||
if len(message_bytes) < 4:
|
||||
return None
|
||||
signed_sender_prefix = message_bytes[:4].hex()
|
||||
message_bytes = message_bytes[4:]
|
||||
try:
|
||||
message_text = message_bytes.decode("utf-8")
|
||||
# Remove null terminator and any padding
|
||||
message_text = message_text.rstrip("\x00")
|
||||
# Truncate at first null terminator (consistent with channel message handling)
|
||||
null_idx = message_text.find("\x00")
|
||||
if null_idx >= 0:
|
||||
message_text = message_text[:null_idx]
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
@@ -540,6 +532,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
message=message_text,
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
signed_sender_prefix=signed_sender_prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -603,3 +596,88 @@ def try_decrypt_dm(
|
||||
return None
|
||||
|
||||
return decrypt_direct_message(packet_info.payload, shared_secret)
|
||||
|
||||
|
||||
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
|
||||
"""Decrypt a PATH payload using the ECDH shared secret."""
|
||||
if len(payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = format(payload[0], "02x")
|
||||
src_hash = format(payload[1], "02x")
|
||||
mac = payload[2:4]
|
||||
ciphertext = payload[4:]
|
||||
|
||||
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||
return None
|
||||
|
||||
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
if calculated_mac != mac:
|
||||
return None
|
||||
|
||||
try:
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(ciphertext)
|
||||
except Exception as e:
|
||||
logger.debug("AES decryption failed for PATH payload: %s", e)
|
||||
return None
|
||||
|
||||
if len(decrypted) < 2:
|
||||
return None
|
||||
|
||||
from app.path_utils import decode_path_byte
|
||||
|
||||
packed_len = decrypted[0]
|
||||
try:
|
||||
returned_path_len, hash_size = decode_path_byte(packed_len)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
path_byte_len = returned_path_len * hash_size
|
||||
if len(decrypted) < 1 + path_byte_len + 1:
|
||||
return None
|
||||
|
||||
offset = 1
|
||||
returned_path = decrypted[offset : offset + path_byte_len]
|
||||
offset += path_byte_len
|
||||
extra_type = decrypted[offset] & 0x0F
|
||||
offset += 1
|
||||
extra = decrypted[offset:]
|
||||
|
||||
return DecryptedPathPayload(
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
returned_path=returned_path,
|
||||
returned_path_len=returned_path_len,
|
||||
returned_path_hash_mode=hash_size - 1,
|
||||
extra_type=extra_type,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def try_decrypt_path(
|
||||
raw_packet: bytes,
|
||||
our_private_key: bytes,
|
||||
their_public_key: bytes,
|
||||
our_public_key: bytes,
|
||||
) -> DecryptedPathPayload | None:
|
||||
"""Try to decrypt a raw packet as a PATH packet."""
|
||||
packet_info = parse_packet(raw_packet)
|
||||
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
|
||||
return None
|
||||
|
||||
if len(packet_info.payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = packet_info.payload[0]
|
||||
src_hash = packet_info.payload[1]
|
||||
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
|
||||
return None
|
||||
|
||||
try:
|
||||
shared_secret = derive_shared_secret(our_private_key, their_public_key)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
|
||||
return None
|
||||
|
||||
return decrypt_path_payload(packet_info.payload, shared_secret)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Shared dependencies for FastAPI routers."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.radio import radio_manager
|
||||
|
||||
|
||||
def require_connected():
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance.
|
||||
|
||||
Raises HTTPException 503 if radio is not connected.
|
||||
"""
|
||||
if getattr(radio_manager, "is_setup_in_progress", False) is True:
|
||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
return radio_manager.meshcore
|
||||
+128
-130
@@ -1,17 +1,24 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact, Message, MessagePath
|
||||
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.dm_ingest import (
|
||||
ingest_fallback_direct_message,
|
||||
resolve_direct_message_sender_metadata,
|
||||
resolve_fallback_direct_message_context,
|
||||
)
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
@@ -25,31 +32,14 @@ logger = logging.getLogger(__name__)
|
||||
_active_subscriptions: list["Subscription"] = []
|
||||
|
||||
|
||||
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
|
||||
_pending_acks: dict[str, tuple[int, float, int]] = {}
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track a pending ACK for a direct message."""
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug(
|
||||
"Tracking pending ACK %s for message %d (timeout %dms)",
|
||||
expected_ack,
|
||||
message_id,
|
||||
timeout_ms,
|
||||
)
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||
"""Compatibility wrapper for pending DM ACK tracking."""
|
||||
return dm_ack_tracker.track_pending_ack(expected_ack, message_id, timeout_ms)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
"""Remove expired pending ACKs."""
|
||||
now = time.time()
|
||||
expired = []
|
||||
for code, (_msg_id, created_at, timeout_ms) in _pending_acks.items():
|
||||
if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer
|
||||
expired.append(code)
|
||||
for code in expired:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
"""Compatibility wrapper for expiring stale DM ACK entries."""
|
||||
dm_ack_tracker.cleanup_expired_acks()
|
||||
|
||||
|
||||
async def on_contact_message(event: "Event") -> None:
|
||||
@@ -62,8 +52,8 @@ async def on_contact_message(event: "Event") -> None:
|
||||
2. The packet processor couldn't match the sender to a known contact
|
||||
|
||||
The packet processor handles: decryption, storage, broadcast, bot trigger.
|
||||
This handler only stores if the packet processor didn't already handle it
|
||||
(detected via INSERT OR IGNORE returning None for duplicates).
|
||||
This handler adapts CONTACT_MSG_RECV payloads into the shared DM ingest
|
||||
workflow, which reconciles duplicates against the packet pipeline when possible.
|
||||
"""
|
||||
payload = event.payload
|
||||
|
||||
@@ -77,92 +67,68 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
|
||||
received_at = int(time.time())
|
||||
|
||||
# Look up contact from database - use prefix lookup only if needed
|
||||
# (get_by_key_or_prefix does exact match first, then prefix fallback)
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(sender_pubkey)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning(
|
||||
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
|
||||
sender_pubkey,
|
||||
)
|
||||
contact = None
|
||||
if contact:
|
||||
sender_pubkey = contact.public_key.lower()
|
||||
|
||||
# Promote any prefix-stored messages to this full key
|
||||
await MessageRepository.claim_prefix_messages(sender_pubkey)
|
||||
|
||||
# Skip messages from repeaters - they only send CLI responses, not chat messages.
|
||||
# CLI responses are handled by the command endpoint and txt_type filter above.
|
||||
if contact.type == CONTACT_TYPE_REPEATER:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (not stored in chat history)",
|
||||
sender_pubkey[:12],
|
||||
)
|
||||
return
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
# If the packet processor already stored this message, this returns None
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=payload.get("text", ""),
|
||||
conversation_key=sender_pubkey,
|
||||
sender_timestamp=payload.get("sender_timestamp") or received_at,
|
||||
context = await resolve_fallback_direct_message_context(
|
||||
sender_public_key=sender_pubkey,
|
||||
received_at=received_at,
|
||||
path=payload.get("path"),
|
||||
broadcast_fn=broadcast_event,
|
||||
contact_repository=ContactRepository,
|
||||
log=logger,
|
||||
)
|
||||
if context.skip_storage:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (not stored in chat history)",
|
||||
context.conversation_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
# Try to create or reconcile the message via the shared DM ingest service.
|
||||
ts = payload.get("sender_timestamp")
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
sender_name = context.sender_name
|
||||
sender_key = context.sender_key
|
||||
signature = payload.get("signature")
|
||||
if (
|
||||
context.contact is not None
|
||||
and context.contact.type == CONTACT_TYPE_ROOM
|
||||
and txt_type == 2
|
||||
and isinstance(signature, str)
|
||||
and signature
|
||||
):
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=signature,
|
||||
received_at=received_at,
|
||||
broadcast_fn=broadcast_event,
|
||||
contact_repository=ContactRepository,
|
||||
log=logger,
|
||||
)
|
||||
message = await ingest_fallback_direct_message(
|
||||
conversation_key=context.conversation_key,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
broadcast_fn=broadcast_event,
|
||||
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
if message is None:
|
||||
# Already handled by packet processor (or exact duplicate) - nothing more to do
|
||||
logger.debug("DM from %s already processed by packet processor", sender_pubkey[:12])
|
||||
logger.debug(
|
||||
"DM from %s already processed by packet processor", context.conversation_key[:12]
|
||||
)
|
||||
return
|
||||
|
||||
# If we get here, the packet processor didn't handle this message
|
||||
# (likely because private key export is not available)
|
||||
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
|
||||
|
||||
# Build paths array for broadcast
|
||||
path = payload.get("path")
|
||||
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None
|
||||
|
||||
# Broadcast the new message
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="PRIV",
|
||||
conversation_key=sender_pubkey,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=payload.get("sender_timestamp") or received_at,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
# Update contact last_contacted (contact was already fetched above)
|
||||
if contact:
|
||||
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
|
||||
|
||||
# Run bot if enabled
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=contact.name if contact else None,
|
||||
sender_key=sender_pubkey,
|
||||
message_text=payload.get("text", ""),
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=payload.get("sender_timestamp"),
|
||||
path=payload.get("path"),
|
||||
is_outgoing=False,
|
||||
)
|
||||
logger.debug(
|
||||
"DM from %s handled by event handler (fallback path)", context.conversation_key[:12]
|
||||
)
|
||||
|
||||
|
||||
@@ -216,6 +182,7 @@ async def on_path_update(event: "Event") -> None:
|
||||
# so if path fields are absent here we treat this as informational only.
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
path_hash_mode = payload.get("path_hash_mode")
|
||||
if path is None or path_len is None:
|
||||
logger.debug(
|
||||
"PATH_UPDATE for %s has no path payload, skipping DB update", contact.public_key[:12]
|
||||
@@ -230,7 +197,28 @@ async def on_path_update(event: "Event") -> None:
|
||||
)
|
||||
return
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, str(path), normalized_path_len)
|
||||
normalized_path_hash_mode: int | None
|
||||
if path_hash_mode is None:
|
||||
# Legacy firmware/library payloads only support 1-byte hop hashes.
|
||||
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
||||
else:
|
||||
try:
|
||||
normalized_path_hash_mode = int(path_hash_mode)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid path_hash_mode in PATH_UPDATE for %s: %r",
|
||||
contact.public_key[:12],
|
||||
path_hash_mode,
|
||||
)
|
||||
normalized_path_hash_mode = None
|
||||
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
str(path),
|
||||
normalized_path_len,
|
||||
normalized_path_hash_mode,
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
|
||||
|
||||
async def on_new_contact(event: "Event") -> None:
|
||||
@@ -248,23 +236,42 @@ async def on_new_contact(event: "Event") -> None:
|
||||
|
||||
logger.debug("New contact: %s", public_key[:12])
|
||||
|
||||
contact_data = {
|
||||
**Contact.from_radio_dict(public_key.lower(), payload, on_radio=True),
|
||||
"last_seen": int(time.time()),
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False)
|
||||
contact_upsert.last_seen = int(time.time())
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=public_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Record name history if contact has a name
|
||||
adv_name = payload.get("adv_name")
|
||||
if adv_name:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key.lower(), adv_name, int(time.time())
|
||||
)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=public_key,
|
||||
contact_name=adv_name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, etc.) matching the REST Contact shape exactly.
|
||||
db_contact = await ContactRepository.get_by_key(public_key)
|
||||
broadcast_event("contact", (db_contact.model_dump() if db_contact else contact_data))
|
||||
broadcast_event(
|
||||
"contact",
|
||||
(
|
||||
db_contact.model_dump()
|
||||
if db_contact
|
||||
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
|
||||
),
|
||||
)
|
||||
if db_contact:
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
@@ -277,18 +284,9 @@ async def on_ack(event: "Event") -> None:
|
||||
return
|
||||
|
||||
logger.debug("Received ACK with code %s", ack_code)
|
||||
|
||||
cleanup_expired_acks()
|
||||
|
||||
if ack_code in _pending_acks:
|
||||
message_id, _, _ = _pending_acks.pop(ack_code)
|
||||
logger.info("ACK received for message %d", message_id)
|
||||
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
# DM ACKs don't carry path data, so paths is intentionally omitted.
|
||||
# The frontend's mergePendingAck handles the missing field correctly,
|
||||
# preserving any previously known paths.
|
||||
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||
if matched:
|
||||
logger.info("ACK received for code %s", ack_code)
|
||||
else:
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Typed WebSocket event contracts and serialization helpers."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast
|
||||
from app.routers.health import HealthResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WsEventType = Literal[
|
||||
"health",
|
||||
"message",
|
||||
"contact",
|
||||
"contact_resolved",
|
||||
"channel",
|
||||
"contact_deleted",
|
||||
"channel_deleted",
|
||||
"raw_packet",
|
||||
"message_acked",
|
||||
"error",
|
||||
"success",
|
||||
]
|
||||
|
||||
|
||||
class ContactDeletedPayload(TypedDict):
|
||||
public_key: str
|
||||
|
||||
|
||||
class ContactResolvedPayload(TypedDict):
|
||||
previous_public_key: str
|
||||
contact: Contact
|
||||
|
||||
|
||||
class ChannelDeletedPayload(TypedDict):
|
||||
key: str
|
||||
|
||||
|
||||
class MessageAckedPayload(TypedDict):
|
||||
message_id: int
|
||||
ack_count: int
|
||||
paths: NotRequired[list[MessagePath]]
|
||||
packet_id: NotRequired[int | None]
|
||||
|
||||
|
||||
class ToastPayload(TypedDict):
|
||||
message: str
|
||||
details: NotRequired[str]
|
||||
|
||||
|
||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
"contact": TypeAdapter(Contact),
|
||||
"contact_resolved": TypeAdapter(ContactResolvedPayload),
|
||||
"channel": TypeAdapter(Channel),
|
||||
"contact_deleted": TypeAdapter(ContactDeletedPayload),
|
||||
"channel_deleted": TypeAdapter(ChannelDeletedPayload),
|
||||
"raw_packet": TypeAdapter(RawPacketBroadcast),
|
||||
"message_acked": TypeAdapter(MessageAckedPayload),
|
||||
"error": TypeAdapter(ToastPayload),
|
||||
"success": TypeAdapter(ToastPayload),
|
||||
}
|
||||
|
||||
|
||||
def dump_ws_event(event_type: str, data: Any) -> str:
|
||||
"""Serialize a WebSocket event envelope with validation for known event types."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return json.dumps({"type": event_type, "data": data})
|
||||
|
||||
try:
|
||||
validated = adapter.validate_python(data)
|
||||
payload = adapter.dump_python(validated, mode="json")
|
||||
return json.dumps({"type": event_type, "data": payload})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to validate WebSocket payload for event %s; falling back to raw JSON envelope",
|
||||
event_type,
|
||||
)
|
||||
return json.dumps({"type": event_type, "data": data})
|
||||
@@ -0,0 +1,311 @@
|
||||
# Fanout Bus Architecture
|
||||
|
||||
The fanout bus is a unified system for dispatching mesh radio events (decoded messages and raw packets) to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### FanoutModule (base.py)
|
||||
Base class that all integration modules extend:
|
||||
- `__init__(config_id, config, *, name="")` — constructor; receives the config UUID, the type-specific config dict, and the user-assigned name
|
||||
- `start()` / `stop()` — async lifecycle (e.g. open/close connections)
|
||||
- `on_message(data)` — receive decoded messages (DM/channel)
|
||||
- `on_raw(data)` — receive raw RF packets
|
||||
- `status` property (**must override**) — return `"connected"`, `"disconnected"`, or `"error"`
|
||||
|
||||
### FanoutManager (manager.py)
|
||||
Singleton that owns all active modules and dispatches events:
|
||||
- `load_from_db()` — startup: load enabled configs, instantiate modules
|
||||
- `reload_config(id)` — CRUD: stop old, start new
|
||||
- `remove_config(id)` — delete: stop and remove
|
||||
- `broadcast_message(data)` — scope-check + dispatch `on_message`
|
||||
- `broadcast_raw(data)` — scope-check + dispatch `on_raw`
|
||||
- `stop_all()` — shutdown
|
||||
- `get_statuses()` — health endpoint data
|
||||
|
||||
All modules are constructed uniformly: `cls(config_id, config_blob, name=cfg.get("name", ""))`.
|
||||
|
||||
### Scope Matching
|
||||
Each config has a `scope` JSON blob controlling what events reach it:
|
||||
```json
|
||||
{"messages": "all", "raw_packets": "all"}
|
||||
{"messages": "none", "raw_packets": "all"}
|
||||
{"messages": {"channels": ["key1"], "contacts": "all"}, "raw_packets": "none"}
|
||||
```
|
||||
Community MQTT always enforces `{"messages": "none", "raw_packets": "all"}`.
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Radio Event -> packet_processor / event_handler
|
||||
-> broadcast_event("message"|"raw_packet", data, realtime=True)
|
||||
-> WebSocket broadcast (always)
|
||||
-> FanoutManager.broadcast_message/raw (only if realtime=True)
|
||||
-> scope check per module
|
||||
-> module.on_message / on_raw
|
||||
```
|
||||
|
||||
Setting `realtime=False` (used during historical decryption) skips fanout dispatch entirely.
|
||||
|
||||
## Current Module Types
|
||||
|
||||
### mqtt_private (mqtt_private.py)
|
||||
Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob:
|
||||
- `broker_host`, `broker_port`, `username`, `password`
|
||||
- `use_tls`, `tls_insecure`, `topic_prefix`
|
||||
|
||||
### mqtt_community (mqtt_community.py)
|
||||
Wraps `CommunityMqttPublisher` from `app/fanout/community_mqtt.py`. Config blob:
|
||||
- `broker_host`, `broker_port`, `iata`, `email`
|
||||
- Only publishes raw packets (on_message is a no-op)
|
||||
- The published `raw` field is always the original packet hex.
|
||||
- When a direct packet includes a `path` field, it is emitted as comma-separated hop identifiers exactly as the packet reports them. Token width varies with the packet's path hash mode (`1`, `2`, or `3` bytes per hop); there is no legacy flat per-byte companion field.
|
||||
|
||||
### bot (bot.py)
|
||||
Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
|
||||
- `code` — Python bot function source code
|
||||
- Executes in a thread pool with timeout and semaphore concurrency control
|
||||
- Rate-limits outgoing messages for repeater compatibility
|
||||
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
|
||||
|
||||
### webhook (webhook.py)
|
||||
HTTP webhook delivery. Config blob:
|
||||
- `url`, `method` (POST/PUT/PATCH)
|
||||
- `hmac_secret` (optional) — when set, each request includes an HMAC-SHA256 signature of the JSON body
|
||||
- `hmac_header` (optional, default `X-Webhook-Signature`) — header name for the signature (value format: `sha256=<hex>`)
|
||||
- `headers` — arbitrary extra headers (JSON object)
|
||||
|
||||
### apprise (apprise_mod.py)
|
||||
Push notifications via Apprise library. Config blob:
|
||||
- `urls` — newline-separated Apprise notification service URLs
|
||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
||||
- `include_path` — include routing path in notification body
|
||||
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
|
||||
|
||||
### sqs (sqs.py)
|
||||
Amazon SQS delivery. Config blob:
|
||||
- `queue_url` — target queue URL
|
||||
- `region_name` (optional; inferred from standard AWS SQS queue URLs when omitted), `endpoint_url` (optional)
|
||||
- `access_key_id`, `secret_access_key`, `session_token` (all optional; blank uses the normal AWS credential chain)
|
||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||
- Supports both decoded messages and raw packets via normal scope selection
|
||||
|
||||
### map_upload (map_upload.py)
|
||||
Uploads heard repeater and room-server advertisements to map.meshcore.dev. Config blob:
|
||||
- `api_url` (optional, default `""`) — upload endpoint; empty falls back to the public map.meshcore.dev API
|
||||
- `dry_run` (bool, default `true`) — when true, logs the payload at INFO level without sending
|
||||
- `geofence_enabled` (bool, default `false`) — when true, only uploads nodes within `geofence_radius_km` of the radio's own configured lat/lon
|
||||
- `geofence_radius_km` (float, default `0`) — filter radius in kilometres
|
||||
|
||||
Geofence notes:
|
||||
- The reference center is always the radio's own `adv_lat`/`adv_lon` from `radio_runtime.meshcore.self_info`, read **live at upload time** — no lat/lon is stored in the fanout config itself.
|
||||
- If the radio's lat/lon is `(0, 0)` or the radio is not connected, the geofence check is silently skipped so uploads continue normally until coordinates are configured.
|
||||
- Requires the radio to have `ENABLE_PRIVATE_KEY_EXPORT=1` firmware to sign uploads.
|
||||
- Scope is always `{"messages": "none", "raw_packets": "all"}` — only raw RF packets are processed.
|
||||
|
||||
## Adding a New Integration Type
|
||||
|
||||
### Step-by-step checklist
|
||||
|
||||
#### 1. Backend module (`app/fanout/my_type.py`)
|
||||
|
||||
Create a class extending `FanoutModule`:
|
||||
|
||||
```python
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
class MyTypeModule(FanoutModule):
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
# Initialize module-specific state
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Open connections, create clients, etc."""
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Close connections, clean up resources."""
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
"""Handle decoded messages. Omit if not needed."""
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
"""Handle raw packets. Omit if not needed."""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Required. Return 'connected', 'disconnected', or 'error'."""
|
||||
...
|
||||
```
|
||||
|
||||
Constructor requirements:
|
||||
- Must accept `config_id: str, config: dict, *, name: str = ""`
|
||||
- Must forward `name` to super: `super().__init__(config_id, config, name=name)`
|
||||
|
||||
#### 2. Register in manager (`app/fanout/manager.py`)
|
||||
|
||||
Add import and mapping in `_register_module_types()`:
|
||||
|
||||
```python
|
||||
from app.fanout.my_type import MyTypeModule
|
||||
_MODULE_TYPES["my_type"] = MyTypeModule
|
||||
```
|
||||
|
||||
#### 3. Router changes (`app/routers/fanout.py`)
|
||||
|
||||
Three changes needed:
|
||||
|
||||
**a)** Add to `_VALID_TYPES` set:
|
||||
```python
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "my_type"}
|
||||
```
|
||||
|
||||
**b)** Add a validation function:
|
||||
```python
|
||||
def _validate_my_type_config(config: dict) -> None:
|
||||
"""Validate my_type config blob."""
|
||||
if not config.get("some_required_field"):
|
||||
raise HTTPException(status_code=400, detail="some_required_field is required")
|
||||
```
|
||||
|
||||
**c)** Wire validation into both `create_fanout_config` and `update_fanout_config` — add an `elif` to the validation block in each:
|
||||
```python
|
||||
elif body.type == "my_type":
|
||||
_validate_my_type_config(body.config)
|
||||
```
|
||||
Note: validation only runs when the config will be enabled (disabled configs are treated as drafts).
|
||||
|
||||
**d)** Add scope enforcement in `_enforce_scope()` if the type has fixed scope constraints (e.g. raw_packets always none). Otherwise it falls through to the `mqtt_private` default which allows both messages and raw_packets to be configurable.
|
||||
|
||||
#### 4. Frontend editor component (`SettingsFanoutSection.tsx`)
|
||||
|
||||
Four changes needed in this single file:
|
||||
|
||||
**a)** Add to `TYPE_LABELS` and `TYPE_OPTIONS` at the top:
|
||||
```tsx
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
// ... existing entries ...
|
||||
my_type: 'My Type',
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
// ... existing entries ...
|
||||
{ value: 'my_type', label: 'My Type' },
|
||||
];
|
||||
```
|
||||
|
||||
**b)** Create an editor component (follows the same pattern as existing editors):
|
||||
```tsx
|
||||
function MyTypeConfigEditor({
|
||||
config,
|
||||
scope,
|
||||
onChange,
|
||||
onScopeChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Type-specific config fields */}
|
||||
<Separator />
|
||||
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`.
|
||||
|
||||
The `ScopeSelector` component is defined within the same file. It accepts an optional `showRawPackets` prop:
|
||||
- **Without `showRawPackets`** (webhook, apprise): shows message scope only (all/only/except — no "none" option since that would make the integration a no-op). A warning appears when the effective selection matches nothing.
|
||||
- **With `showRawPackets`** (private MQTT): adds a "Forward raw packets" toggle and includes the "No messages" option (valid when raw packets are enabled). The warning appears only when both raw packets and messages are effectively disabled.
|
||||
|
||||
**c)** Add default config and scope in `handleAddCreate`:
|
||||
```tsx
|
||||
const defaults: Record<string, Record<string, unknown>> = {
|
||||
// ... existing entries ...
|
||||
my_type: { some_field: '', other_field: true },
|
||||
};
|
||||
const defaultScopes: Record<string, Record<string, unknown>> = {
|
||||
// ... existing entries ...
|
||||
my_type: { messages: 'all', raw_packets: 'none' },
|
||||
};
|
||||
```
|
||||
|
||||
**d)** Wire the editor into the detail view's conditional render block:
|
||||
```tsx
|
||||
{editingConfig.type === 'my_type' && (
|
||||
<MyTypeConfigEditor
|
||||
config={editConfig}
|
||||
scope={editScope}
|
||||
onChange={setEditConfig}
|
||||
onScopeChange={setEditScope}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 5. Tests
|
||||
|
||||
**Backend integration tests** (`tests/test_fanout_integration.py`):
|
||||
- Test that a configured + enabled module receives messages via `FanoutManager.broadcast_message`
|
||||
- Test scope filtering (all, none, selective)
|
||||
- Test that a disabled module does not receive messages
|
||||
|
||||
**Backend unit tests** (`tests/test_fanout_hitlist.py` or a dedicated file):
|
||||
- Test config validation (required fields, bad values)
|
||||
- Test module-specific logic in isolation
|
||||
|
||||
**Frontend tests** (`frontend/src/test/fanoutSection.test.tsx`):
|
||||
- The existing suite covers the list/edit/create flow generically. If your editor has special behavior, add specific test cases.
|
||||
|
||||
#### Summary of files to touch
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `app/fanout/my_type.py` | New module class |
|
||||
| `app/fanout/manager.py` | Import + register in `_register_module_types()` |
|
||||
| `app/routers/fanout.py` | `_VALID_TYPES` + validator function + scope enforcement |
|
||||
| `frontend/.../SettingsFanoutSection.tsx` | `TYPE_LABELS` + `TYPE_OPTIONS` + editor component + defaults + detail view wiring |
|
||||
| `tests/test_fanout_integration.py` | Integration tests |
|
||||
|
||||
## REST API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new config |
|
||||
| PATCH | `/api/fanout/{id}` | Update config (triggers module reload) |
|
||||
| DELETE | `/api/fanout/{id}` | Delete config (stops module) |
|
||||
|
||||
## Database
|
||||
|
||||
`fanout_configs` table:
|
||||
- `id` TEXT PRIMARY KEY
|
||||
- `type`, `name`, `enabled`, `config` (JSON), `scope` (JSON)
|
||||
- `sort_order`, `created_at`
|
||||
|
||||
Migrations:
|
||||
- **36**: Creates `fanout_configs` table, migrates existing MQTT settings from `app_settings`
|
||||
- **37**: Migrates bot configs from `app_settings.bots` JSON column into fanout rows
|
||||
- **38**: Drops legacy `mqtt_*`, `community_mqtt_*`, and `bots` columns from `app_settings`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `app/fanout/base.py` — FanoutModule base class
|
||||
- `app/fanout/manager.py` — FanoutManager singleton
|
||||
- `app/fanout/mqtt_base.py` — BaseMqttPublisher ABC (shared MQTT connection loop)
|
||||
- `app/fanout/mqtt.py` — MqttPublisher (private MQTT publishing)
|
||||
- `app/fanout/community_mqtt.py` — CommunityMqttPublisher (community MQTT with JWT auth)
|
||||
- `app/fanout/mqtt_private.py` — Private MQTT fanout module
|
||||
- `app/fanout/mqtt_community.py` — Community MQTT fanout module
|
||||
- `app/fanout/bot.py` — Bot fanout module
|
||||
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
|
||||
- `app/fanout/webhook.py` — Webhook fanout module
|
||||
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
||||
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
||||
- `app/fanout/map_upload.py` — Map Upload fanout module
|
||||
- `app/repository/fanout.py` — Database CRUD
|
||||
- `app/routers/fanout.py` — REST API
|
||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||
- `frontend/src/components/settings/SettingsFanoutSection.tsx` — UI
|
||||
@@ -0,0 +1,8 @@
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.manager import FanoutManager, fanout_manager
|
||||
|
||||
__all__ = [
|
||||
"FanoutManager",
|
||||
"FanoutModule",
|
||||
"fanout_manager",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Fanout module for Apprise push notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from app.fanout.base import FanoutModule, get_fanout_message_text
|
||||
from app.path_utils import split_path_hex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_urls(raw: str) -> list[str]:
|
||||
"""Split multi-line URL string into individual URLs."""
|
||||
return [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _normalize_discord_url(url: str) -> str:
|
||||
"""Add avatar=no to Discord URLs to suppress identity override."""
|
||||
parts = urlsplit(url)
|
||||
scheme = parts.scheme.lower()
|
||||
host = parts.netloc.lower()
|
||||
|
||||
is_discord = scheme in ("discord", "discords") or (
|
||||
scheme in ("http", "https")
|
||||
and host in ("discord.com", "discordapp.com")
|
||||
and parts.path.lower().startswith("/api/webhooks/")
|
||||
)
|
||||
if not is_discord:
|
||||
return url
|
||||
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["avatar"] = "no"
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
|
||||
|
||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
"""Build a human-readable notification body from message data."""
|
||||
msg_type = data.get("type", "")
|
||||
text = get_fanout_message_text(data)
|
||||
sender_name = data.get("sender_name") or "Unknown"
|
||||
|
||||
via = ""
|
||||
if include_path:
|
||||
paths = data.get("paths")
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||
path_str = first_path.get("path", "")
|
||||
path_len = first_path.get("path_len")
|
||||
else:
|
||||
path_str = None
|
||||
path_len = None
|
||||
|
||||
if msg_type == "PRIV" and path_str is None:
|
||||
via = " **via:** [`direct`]"
|
||||
elif path_str is not None:
|
||||
path_str = path_str.strip().lower()
|
||||
if path_str == "":
|
||||
via = " **via:** [`direct`]"
|
||||
else:
|
||||
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
||||
hops = split_path_hex(path_str, hop_count)
|
||||
if hops:
|
||||
hop_list = ", ".join(f"`{h}`" for h in hops)
|
||||
via = f" **via:** [{hop_list}]"
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"**DM:** {sender_name}: {text}{via}"
|
||||
|
||||
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
|
||||
return f"**{channel_name}:** {sender_name}: {text}{via}"
|
||||
|
||||
|
||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||
"""Send notification synchronously via Apprise. Returns True on success."""
|
||||
import apprise as apprise_lib
|
||||
|
||||
urls = _parse_urls(urls_raw)
|
||||
if not urls:
|
||||
return False
|
||||
|
||||
notifier = apprise_lib.Apprise()
|
||||
for url in urls:
|
||||
if preserve_identity:
|
||||
url = _normalize_discord_url(url)
|
||||
notifier.add(url)
|
||||
|
||||
return bool(notifier.notify(title="", body=body))
|
||||
|
||||
|
||||
class AppriseModule(FanoutModule):
|
||||
"""Sends push notifications via Apprise for incoming messages."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
# Skip outgoing messages — only notify on incoming
|
||||
if data.get("outgoing"):
|
||||
return
|
||||
|
||||
urls = self.config.get("urls", "")
|
||||
if not urls or not urls.strip():
|
||||
return
|
||||
|
||||
preserve_identity = self.config.get("preserve_identity", True)
|
||||
include_path = self.config.get("include_path", True)
|
||||
body = _format_body(data, include_path=include_path)
|
||||
|
||||
try:
|
||||
success = await asyncio.to_thread(
|
||||
_send_sync, urls, body, preserve_identity=preserve_identity
|
||||
)
|
||||
self._set_last_error(None if success else "Apprise notify returned failure")
|
||||
if not success:
|
||||
logger.warning("Apprise notification failed for module %s", self.config_id)
|
||||
except Exception as exc:
|
||||
self._set_last_error(str(exc))
|
||||
logger.exception("Apprise send error for module %s", self.config_id)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("urls", "").strip():
|
||||
return "disconnected"
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Base class for fanout integration modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _broadcast_fanout_health() -> None:
|
||||
"""Push updated fanout status to connected frontend clients."""
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
|
||||
class FanoutModule:
|
||||
"""Base class for all fanout integrations.
|
||||
|
||||
Each module wraps a specific integration (MQTT, webhook, etc.) and
|
||||
receives dispatched messages/packets from the FanoutManager.
|
||||
|
||||
Subclasses must override the ``status`` property.
|
||||
"""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
self.config_id = config_id
|
||||
self.config = config
|
||||
self.name = name
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the module (e.g. connect to broker). Override for persistent connections."""
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the module (e.g. disconnect from broker)."""
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
"""Called for decoded messages (DM/channel). Override if needed."""
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
"""Called for raw RF packets. Override if needed."""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Return 'connected', 'disconnected', or 'error'."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
"""Return the most recent retained operator-facing error, if any."""
|
||||
return self._last_error
|
||||
|
||||
def _set_last_error(self, value: str | None) -> None:
|
||||
"""Update the retained error and broadcast health when it changes."""
|
||||
if self._last_error == value:
|
||||
return
|
||||
self._last_error = value
|
||||
_broadcast_fanout_health()
|
||||
|
||||
|
||||
def get_fanout_message_text(data: dict) -> str:
|
||||
"""Return the best human-readable message body for fanout consumers.
|
||||
|
||||
Channel messages are stored with the rendered sender label embedded in the
|
||||
text (for example ``"Alice: hello"``). Human-facing integrations that also
|
||||
carry ``sender_name`` should strip that duplicated prefix when it matches
|
||||
the payload sender exactly.
|
||||
"""
|
||||
|
||||
text = data.get("text", "")
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
|
||||
if data.get("type") != "CHAN":
|
||||
return text
|
||||
|
||||
sender_name = data.get("sender_name")
|
||||
if not isinstance(sender_name, str) or not sender_name:
|
||||
return text
|
||||
|
||||
prefix, separator, remainder = text.partition(": ")
|
||||
if separator and prefix == sender_name:
|
||||
return remainder
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Fanout module wrapping bot execution logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _derive_path_bytes_per_hop(paths: object, path_value: str | None) -> int | None:
|
||||
"""Derive hop width from the first serialized message path when possible."""
|
||||
if not isinstance(path_value, str) or not path_value:
|
||||
return None
|
||||
if not isinstance(paths, list) or not paths:
|
||||
return None
|
||||
|
||||
first_path = paths[0]
|
||||
if not isinstance(first_path, dict):
|
||||
return None
|
||||
|
||||
path_hops = first_path.get("path_len")
|
||||
if not isinstance(path_hops, int) or path_hops <= 0:
|
||||
return None
|
||||
|
||||
path_hex_chars = len(path_value)
|
||||
if path_hex_chars % 2 != 0:
|
||||
return None
|
||||
|
||||
path_bytes = path_hex_chars // 2
|
||||
if path_bytes % path_hops != 0:
|
||||
return None
|
||||
|
||||
hop_width = path_bytes // path_hops
|
||||
if hop_width not in (1, 2, 3):
|
||||
return None
|
||||
|
||||
return hop_width
|
||||
|
||||
|
||||
class BotModule(FanoutModule):
|
||||
"""Wraps a single bot's code execution and response routing.
|
||||
|
||||
Each BotModule represents one bot configuration. It receives decoded
|
||||
messages via ``on_message``, executes the bot's Python code in a
|
||||
background task (after a 2-second settle delay), and sends any response
|
||||
back through the radio.
|
||||
"""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._active = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._active = False
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
# Wait briefly for tasks to acknowledge cancellation
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
self._tasks.clear()
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
"""Kick off bot execution in a background task so we don't block dispatch."""
|
||||
task = asyncio.create_task(self._run_for_message(data))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async def _run_for_message(self, data: dict) -> None:
|
||||
from app.fanout.bot_exec import (
|
||||
BOT_EXECUTION_TIMEOUT,
|
||||
execute_bot_code,
|
||||
process_bot_response,
|
||||
)
|
||||
|
||||
code = self.config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
return
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
is_dm = msg_type == "PRIV"
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
logger.debug(
|
||||
"Bot '%s' starting for type=%s conversation=%s outgoing=%s",
|
||||
self.name,
|
||||
msg_type or "unknown",
|
||||
conversation_key[:12] if conversation_key else "(none)",
|
||||
bool(data.get("outgoing", False)),
|
||||
)
|
||||
|
||||
# Extract bot parameters from broadcast data
|
||||
if is_dm:
|
||||
sender_key = data.get("sender_key") or conversation_key
|
||||
is_outgoing = data.get("outgoing", False)
|
||||
message_text = data.get("text", "")
|
||||
channel_key = None
|
||||
channel_name = None
|
||||
|
||||
# Outgoing DMs: sender is us, not the contact
|
||||
if is_outgoing:
|
||||
sender_name = None
|
||||
else:
|
||||
sender_name = data.get("sender_name")
|
||||
if sender_name is None:
|
||||
from app.repository import ContactRepository
|
||||
|
||||
contact = await ContactRepository.get_by_key(conversation_key)
|
||||
sender_name = contact.name if contact else None
|
||||
else:
|
||||
sender_key = None
|
||||
is_outgoing = bool(data.get("outgoing", False))
|
||||
sender_name = data.get("sender_name")
|
||||
channel_key = conversation_key
|
||||
|
||||
channel_name = data.get("channel_name")
|
||||
if channel_name is None:
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
channel = await ChannelRepository.get_by_key(conversation_key)
|
||||
channel_name = channel.name if channel else None
|
||||
|
||||
# Strip "sender: " prefix from channel message text
|
||||
text = data.get("text", "")
|
||||
if sender_name and text.startswith(f"{sender_name}: "):
|
||||
message_text = text[len(f"{sender_name}: ") :]
|
||||
else:
|
||||
message_text = text
|
||||
|
||||
sender_timestamp = data.get("sender_timestamp")
|
||||
path_value = data.get("path")
|
||||
paths = data.get("paths")
|
||||
# Message model serializes paths as list of dicts; extract first path string
|
||||
if path_value is None and paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
|
||||
|
||||
# Wait for message to settle (allows retransmissions to be deduped)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Execute bot code in thread pool with timeout
|
||||
from app.fanout.bot_exec import _bot_executor, _bot_semaphore
|
||||
|
||||
async with _bot_semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_bot_executor,
|
||||
execute_bot_code,
|
||||
code,
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path_value,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Bot '%s' execution timed out", self.name)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Bot '%s' execution error", self.name)
|
||||
return
|
||||
|
||||
if response and self._active:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "connected"
|
||||
+151
-133
@@ -15,6 +15,7 @@ import inspect
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -39,6 +40,102 @@ _bot_send_lock = asyncio.Lock()
|
||||
_last_bot_send_time: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BotCallPlan:
|
||||
"""How to call a validated bot() function."""
|
||||
|
||||
call_style: str
|
||||
keyword_args: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
|
||||
"""Validate bot() signature and return a supported call plan."""
|
||||
try:
|
||||
sig = (
|
||||
bot_func_or_sig
|
||||
if isinstance(bot_func_or_sig, inspect.Signature)
|
||||
else inspect.signature(bot_func_or_sig)
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Bot function signature could not be inspected") from exc
|
||||
|
||||
params = sig.parameters
|
||||
param_values = tuple(params.values())
|
||||
positional_params = [
|
||||
p
|
||||
for p in param_values
|
||||
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
|
||||
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
|
||||
explicit_optional_names = tuple(
|
||||
name for name in ("is_outgoing", "path_bytes_per_hop") if name in params
|
||||
)
|
||||
unsupported_required_kwonly = [
|
||||
p.name
|
||||
for p in param_values
|
||||
if p.kind == inspect.Parameter.KEYWORD_ONLY
|
||||
and p.default is inspect.Parameter.empty
|
||||
and p.name not in {"is_outgoing", "path_bytes_per_hop"}
|
||||
]
|
||||
if unsupported_required_kwonly:
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Unsupported required keyword-only "
|
||||
"parameters: " + ", ".join(unsupported_required_kwonly)
|
||||
)
|
||||
|
||||
positional_capacity = len(positional_params)
|
||||
base_args = [object()] * 8
|
||||
base_keyword_args: dict[str, object] = {
|
||||
"sender_name": object(),
|
||||
"sender_key": object(),
|
||||
"message_text": object(),
|
||||
"is_dm": object(),
|
||||
"channel_key": object(),
|
||||
"channel_name": object(),
|
||||
"sender_timestamp": object(),
|
||||
"path": object(),
|
||||
}
|
||||
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
|
||||
keyword_args = dict(base_keyword_args)
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
keyword_args["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
keyword_args["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("keyword", [], keyword_args))
|
||||
|
||||
if not has_kwargs and explicit_optional_names:
|
||||
kwargs: dict[str, object] = {}
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
kwargs["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
kwargs["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("mixed_keyword", base_args, kwargs))
|
||||
|
||||
if has_varargs or positional_capacity >= 10:
|
||||
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
|
||||
if has_varargs or positional_capacity >= 9:
|
||||
candidate_specs.append(("positional_9", base_args + [False], {}))
|
||||
if has_varargs or positional_capacity >= 8:
|
||||
candidate_specs.append(("legacy", base_args, {}))
|
||||
|
||||
for call_style, args, kwargs in candidate_specs:
|
||||
try:
|
||||
sig.bind(*args, **kwargs)
|
||||
except TypeError:
|
||||
continue
|
||||
if call_style in {"keyword", "mixed_keyword"}:
|
||||
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
|
||||
return BotCallPlan(call_style=call_style)
|
||||
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Use the default bot template as a reference. "
|
||||
"Supported trailing parameters are: path; path + is_outgoing; "
|
||||
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
|
||||
"or use **kwargs for forward compatibility."
|
||||
)
|
||||
|
||||
|
||||
def execute_bot_code(
|
||||
code: str,
|
||||
sender_name: str | None,
|
||||
@@ -50,17 +147,19 @@ def execute_bot_code(
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
path_bytes_per_hop: int | None = None,
|
||||
) -> str | list[str] | None:
|
||||
"""
|
||||
Execute user-provided bot code with message context.
|
||||
|
||||
The code should define a function:
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop)`
|
||||
or use named parameters / `**kwargs`.
|
||||
that returns either None (no response), a string (single response message),
|
||||
or a list of strings (multiple messages sent in order).
|
||||
|
||||
Legacy bot functions with 8 parameters (without is_outgoing) are detected
|
||||
via inspect and called without the new parameter for backward compatibility.
|
||||
Legacy bot functions with older signatures are detected via inspect and
|
||||
called without the newer parameters for backward compatibility.
|
||||
|
||||
Args:
|
||||
code: Python code defining the bot function
|
||||
@@ -73,6 +172,7 @@ def execute_bot_code(
|
||||
sender_timestamp: Sender's timestamp from the message (may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
|
||||
|
||||
Returns:
|
||||
Response string, list of strings, or None.
|
||||
@@ -90,8 +190,8 @@ def execute_bot_code(
|
||||
try:
|
||||
# Execute the user's code to define the bot function
|
||||
exec(code, namespace)
|
||||
except Exception as e:
|
||||
logger.warning("Bot code compilation failed: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot code compilation failed")
|
||||
return None
|
||||
|
||||
# Check if bot function was defined
|
||||
@@ -100,30 +200,28 @@ def execute_bot_code(
|
||||
return None
|
||||
|
||||
bot_func = namespace["bot"]
|
||||
|
||||
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
|
||||
# or uses the legacy 8-param signature, for backward compatibility.
|
||||
# Three cases: explicit is_outgoing param or 9+ params (positional),
|
||||
# **kwargs (pass as keyword), or legacy 8-param (omit).
|
||||
call_style = "legacy" # "positional", "keyword", or "legacy"
|
||||
try:
|
||||
sig = inspect.signature(bot_func)
|
||||
params = sig.parameters
|
||||
non_variadic = [
|
||||
p
|
||||
for p in params.values()
|
||||
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
if "is_outgoing" in params or len(non_variadic) >= 9:
|
||||
call_style = "positional"
|
||||
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||
call_style = "keyword"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
call_plan = _analyze_bot_signature(bot_func)
|
||||
except ValueError as exc:
|
||||
logger.error("%s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Call the bot function with appropriate signature
|
||||
if call_style == "positional":
|
||||
if call_plan.call_style == "positional_10":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
)
|
||||
elif call_plan.call_style == "positional_9":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
@@ -135,18 +233,29 @@ def execute_bot_code(
|
||||
path,
|
||||
is_outgoing,
|
||||
)
|
||||
elif call_style == "keyword":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing=is_outgoing,
|
||||
)
|
||||
elif call_plan.call_style == "keyword":
|
||||
keyword_args: dict[str, Any] = {}
|
||||
if "sender_name" in call_plan.keyword_args:
|
||||
keyword_args["sender_name"] = sender_name
|
||||
if "sender_key" in call_plan.keyword_args:
|
||||
keyword_args["sender_key"] = sender_key
|
||||
if "message_text" in call_plan.keyword_args:
|
||||
keyword_args["message_text"] = message_text
|
||||
if "is_dm" in call_plan.keyword_args:
|
||||
keyword_args["is_dm"] = is_dm
|
||||
if "channel_key" in call_plan.keyword_args:
|
||||
keyword_args["channel_key"] = channel_key
|
||||
if "channel_name" in call_plan.keyword_args:
|
||||
keyword_args["channel_name"] = channel_name
|
||||
if "sender_timestamp" in call_plan.keyword_args:
|
||||
keyword_args["sender_timestamp"] = sender_timestamp
|
||||
if "path" in call_plan.keyword_args:
|
||||
keyword_args["path"] = path
|
||||
if "is_outgoing" in call_plan.keyword_args:
|
||||
keyword_args["is_outgoing"] = is_outgoing
|
||||
if "path_bytes_per_hop" in call_plan.keyword_args:
|
||||
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
|
||||
result = bot_func(**keyword_args)
|
||||
else:
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
@@ -172,8 +281,8 @@ def execute_bot_code(
|
||||
logger.debug("Bot function returned unsupported type: %s", type(result))
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Bot function execution failed: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot function execution failed")
|
||||
return None
|
||||
|
||||
|
||||
@@ -249,102 +358,11 @@ async def _send_single_bot_message(
|
||||
logger.warning("Cannot send bot response: no destination")
|
||||
return # Don't update timestamp if we didn't send
|
||||
except HTTPException as e:
|
||||
logger.error("Bot failed to send response: %s", e.detail)
|
||||
logger.error("Bot failed to send response: %s", e.detail, exc_info=True)
|
||||
return # Don't update timestamp on failure
|
||||
except Exception as e:
|
||||
logger.error("Bot failed to send response: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot failed to send response")
|
||||
return # Don't update timestamp on failure
|
||||
|
||||
# Update last send time after successful send
|
||||
_last_bot_send_time = time.monotonic()
|
||||
|
||||
|
||||
async def run_bot_for_message(
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
message_text: str,
|
||||
is_dm: bool,
|
||||
channel_key: str | None,
|
||||
channel_name: str | None = None,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
is_outgoing: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Run all enabled bots for a message (incoming or outgoing).
|
||||
|
||||
This is the main entry point called by message handlers after
|
||||
a message is successfully decrypted and stored. Bots run serially,
|
||||
and errors in one bot don't prevent others from running.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of the sender
|
||||
sender_key: 64-char hex public key of sender (DMs only, None for channels)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel messages
|
||||
channel_key: Channel key for channel messages
|
||||
channel_name: Channel name (e.g. "#general"), None for DMs
|
||||
sender_timestamp: Sender's timestamp from the message
|
||||
path: Hex-encoded routing path
|
||||
is_outgoing: Whether this is our own outgoing message
|
||||
"""
|
||||
# Early check if any bots are enabled (will re-check after sleep)
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
settings = await AppSettingsRepository.get()
|
||||
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
|
||||
if not enabled_bots:
|
||||
return
|
||||
|
||||
async with _bot_semaphore:
|
||||
logger.debug(
|
||||
"Running %d bot(s) for message from %s (is_dm=%s)",
|
||||
len(enabled_bots),
|
||||
sender_name or (sender_key[:12] if sender_key else "unknown"),
|
||||
is_dm,
|
||||
)
|
||||
|
||||
# Wait for the initiating message's retransmissions to propagate through the mesh
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Re-check settings after sleep (user may have changed bot config)
|
||||
settings = await AppSettingsRepository.get()
|
||||
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
|
||||
if not enabled_bots:
|
||||
logger.debug("All bots disabled during wait, skipping")
|
||||
return
|
||||
|
||||
# Run each enabled bot serially
|
||||
loop = asyncio.get_event_loop()
|
||||
for bot in enabled_bots:
|
||||
logger.debug("Executing bot '%s'", bot.name)
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_bot_executor,
|
||||
execute_bot_code,
|
||||
bot.code,
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Bot '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
|
||||
)
|
||||
continue # Continue to next bot
|
||||
except Exception as e:
|
||||
logger.warning("Bot '%s' execution error: %s", bot.name, e)
|
||||
continue # Continue to next bot
|
||||
|
||||
# Send response if any
|
||||
if response:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Community MQTT publisher for sharing raw packets with the MeshCore community.
|
||||
|
||||
Publishes raw packet data to mqtt-us-v1.letsmesh.net using the protocol
|
||||
defined by meshcore-packet-capture (https://github.com/agessaman/meshcore-packet-capture).
|
||||
|
||||
Authentication uses Ed25519 JWT tokens signed with the radio's private key.
|
||||
This module is independent from the private MqttPublisher in app/mqtt.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
from app.keystore import ed25519_sign_expanded
|
||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
|
||||
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
|
||||
_CLIENT_ID = "RemoteTerm"
|
||||
|
||||
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
|
||||
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
|
||||
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
||||
|
||||
# Periodic status republish interval (matches meshcore-packet-capture reference)
|
||||
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
||||
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
||||
|
||||
# Route type mapping: bottom 2 bits of first byte
|
||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||
|
||||
|
||||
class CommunityMqttSettings(Protocol):
|
||||
"""Attributes expected on the settings object for the community MQTT publisher."""
|
||||
|
||||
community_mqtt_enabled: bool
|
||||
community_mqtt_broker_host: str
|
||||
community_mqtt_broker_port: int
|
||||
community_mqtt_transport: str
|
||||
community_mqtt_use_tls: bool
|
||||
community_mqtt_tls_verify: bool
|
||||
community_mqtt_auth_mode: str
|
||||
community_mqtt_username: str
|
||||
community_mqtt_password: str
|
||||
community_mqtt_iata: str
|
||||
community_mqtt_email: str
|
||||
community_mqtt_token_audience: str
|
||||
|
||||
|
||||
def _base64url_encode(data: bytes) -> str:
|
||||
"""Base64url encode without padding."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _generate_jwt_token(
|
||||
private_key: bytes,
|
||||
public_key: bytes,
|
||||
*,
|
||||
audience: str = _DEFAULT_BROKER,
|
||||
email: str = "",
|
||||
) -> str:
|
||||
"""Generate a JWT token for community MQTT authentication.
|
||||
|
||||
Creates a token with Ed25519 signature using MeshCore's expanded key format.
|
||||
Token format: header_b64.payload_b64.signature_hex
|
||||
|
||||
Optional ``email`` embeds a node-claiming identity so the community
|
||||
aggregator can associate this radio with an owner.
|
||||
"""
|
||||
header = {"alg": "Ed25519", "typ": "JWT"}
|
||||
now = int(time.time())
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
payload: dict[str, object] = {
|
||||
"publicKey": pubkey_hex,
|
||||
"iat": now,
|
||||
"exp": now + _TOKEN_LIFETIME,
|
||||
"aud": audience,
|
||||
"owner": pubkey_hex,
|
||||
"client": _get_client_version(),
|
||||
}
|
||||
if email:
|
||||
payload["email"] = email
|
||||
|
||||
header_b64 = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
|
||||
scalar = private_key[:32]
|
||||
prefix = private_key[32:]
|
||||
signature = ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
|
||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||
|
||||
|
||||
def _calculate_packet_hash(raw_bytes: bytes) -> str:
|
||||
"""Calculate packet hash matching MeshCore's Packet::calculatePacketHash().
|
||||
|
||||
Parses the packet structure to extract payload type and payload data,
|
||||
then hashes: payload_type(1 byte) [+ path_len(2 bytes LE) for TRACE] + payload_data.
|
||||
Returns first 16 hex characters (uppercase).
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return "0" * 16
|
||||
|
||||
try:
|
||||
envelope = parse_packet_envelope(raw_bytes)
|
||||
if envelope is None:
|
||||
return "0" * 16
|
||||
|
||||
# Hash: payload_type(1 byte) [+ path_byte as uint16_t LE for TRACE] + payload_data
|
||||
# IMPORTANT: TRACE hash uses the raw wire byte (not decoded hop count) to match firmware.
|
||||
hash_obj = hashlib.sha256()
|
||||
hash_obj.update(bytes([envelope.payload_type]))
|
||||
if envelope.payload_type == 9: # PAYLOAD_TYPE_TRACE
|
||||
hash_obj.update(envelope.path_byte.to_bytes(2, byteorder="little"))
|
||||
hash_obj.update(envelope.payload)
|
||||
|
||||
return hash_obj.hexdigest()[:16].upper()
|
||||
except Exception:
|
||||
return "0" * 16
|
||||
|
||||
|
||||
def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], int | None]:
|
||||
"""Decode packet fields used by the community uploader payload format.
|
||||
|
||||
Returns:
|
||||
(route_letter, packet_type_str, payload_len_str, path_values, payload_type_int)
|
||||
"""
|
||||
# Reference defaults when decode fails
|
||||
route = "U"
|
||||
packet_type = "0"
|
||||
payload_len = "0"
|
||||
path_values: list[str] = []
|
||||
payload_type: int | None = None
|
||||
|
||||
try:
|
||||
envelope = parse_packet_envelope(raw_bytes)
|
||||
if envelope is None or envelope.payload_version != 0:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
payload_type = envelope.payload_type
|
||||
route = _ROUTE_MAP.get(envelope.route_type, "U")
|
||||
packet_type = str(payload_type)
|
||||
payload_len = str(len(envelope.payload))
|
||||
path_values = split_path_hex(envelope.path.hex(), envelope.hop_count)
|
||||
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
except Exception:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
|
||||
def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: str) -> dict:
|
||||
"""Convert a RawPacketBroadcast dict to meshcore-packet-capture format."""
|
||||
raw_hex = data.get("data", "")
|
||||
raw_bytes = bytes.fromhex(raw_hex) if raw_hex else b""
|
||||
|
||||
route, packet_type, payload_len, path_values, _payload_type = _decode_packet_fields(raw_bytes)
|
||||
|
||||
# Reference format uses local "now" timestamp and derived time/date fields.
|
||||
current_time = datetime.now()
|
||||
ts_str = current_time.isoformat()
|
||||
|
||||
# Keep numeric telemetry numeric so downstream analyzers can ingest it.
|
||||
# Preserve the existing "Unknown" fallback for missing values.
|
||||
snr_val = data.get("snr")
|
||||
rssi_val = data.get("rssi")
|
||||
snr: float | str = float(snr_val) if snr_val is not None else "Unknown"
|
||||
rssi: int | str = int(rssi_val) if rssi_val is not None else "Unknown"
|
||||
|
||||
packet_hash = _calculate_packet_hash(raw_bytes)
|
||||
|
||||
packet = {
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": public_key_hex.upper(),
|
||||
"timestamp": ts_str,
|
||||
"type": "PACKET",
|
||||
"direction": "rx",
|
||||
"time": current_time.strftime("%H:%M:%S"),
|
||||
"date": current_time.strftime("%d/%m/%Y"),
|
||||
"len": str(len(raw_bytes)),
|
||||
"packet_type": packet_type,
|
||||
"route": route,
|
||||
"payload_len": payload_len,
|
||||
"raw": raw_hex.upper(),
|
||||
"SNR": snr,
|
||||
"RSSI": rssi,
|
||||
"hash": packet_hash,
|
||||
}
|
||||
|
||||
if route == "D":
|
||||
packet["path"] = ",".join(path_values)
|
||||
|
||||
return packet
|
||||
|
||||
|
||||
def _build_status_topic(settings: CommunityMqttSettings, pubkey_hex: str) -> str:
|
||||
"""Build the ``meshcore/{IATA}/{PUBKEY}/status`` topic string."""
|
||||
iata = settings.community_mqtt_iata.upper().strip()
|
||||
return f"meshcore/{iata}/{pubkey_hex}/status"
|
||||
|
||||
|
||||
def _build_radio_info() -> str:
|
||||
"""Format the radio parameters string from self_info.
|
||||
|
||||
Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw
|
||||
values). Falls back to ``"0,0,0,0"`` when unavailable.
|
||||
"""
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
try:
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
info = radio_manager.meshcore.self_info
|
||||
freq = info.get("radio_freq", 0)
|
||||
bw = info.get("radio_bw", 0)
|
||||
sf = info.get("radio_sf", 0)
|
||||
cr = info.get("radio_cr", 0)
|
||||
return f"{freq},{bw},{sf},{cr}"
|
||||
except Exception:
|
||||
pass
|
||||
return "0,0,0,0"
|
||||
|
||||
|
||||
def _get_client_version() -> str:
|
||||
"""Return the canonical client/version identifier for community MQTT."""
|
||||
build = get_app_build_info()
|
||||
commit_hash = build.commit_hash or "unknown"
|
||||
return f"{_CLIENT_ID}/{build.version}-{commit_hash}"
|
||||
|
||||
|
||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
"""Manages the community MQTT connection and publishes raw packets."""
|
||||
|
||||
_backoff_max = 60
|
||||
_log_prefix = "Community MQTT"
|
||||
_not_configured_timeout: float | None = 30
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._key_unavailable_warned: bool = False
|
||||
self._cached_device_info: dict[str, str] | None = None
|
||||
self._cached_stats: dict[str, Any] | None = None
|
||||
self._stats_supported: bool | None = None
|
||||
self._last_stats_fetch: float = 0.0
|
||||
self._last_status_publish: float = 0.0
|
||||
|
||||
async def start(self, settings: object) -> None:
|
||||
self._key_unavailable_warned = False
|
||||
self._cached_device_info = None
|
||||
self._cached_stats = None
|
||||
self._stats_supported = None
|
||||
self._last_stats_fetch = 0.0
|
||||
self._last_status_publish = 0.0
|
||||
await super().start(settings)
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
from app.keystore import get_public_key, has_private_key
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
auth_mode = getattr(s, "community_mqtt_auth_mode", "token") if s else "token"
|
||||
if (
|
||||
s
|
||||
and auth_mode == "token"
|
||||
and get_public_key() is not None
|
||||
and not has_private_key()
|
||||
and not self._key_unavailable_warned
|
||||
):
|
||||
broadcast_error(
|
||||
"Community MQTT unavailable",
|
||||
"Radio firmware does not support private key export.",
|
||||
)
|
||||
self._key_unavailable_warned = True
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if community MQTT is enabled and keys are available."""
|
||||
from app.keystore import get_public_key, has_private_key
|
||||
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
if not s or not s.community_mqtt_enabled:
|
||||
return False
|
||||
if get_public_key() is None:
|
||||
return False
|
||||
auth_mode = getattr(s, "community_mqtt_auth_mode", "token")
|
||||
if auth_mode == "token":
|
||||
return has_private_key()
|
||||
return True
|
||||
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
s: CommunityMqttSettings = settings # type: ignore[assignment]
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
assert public_key is not None # guaranteed by _pre_connect
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
transport = s.community_mqtt_transport or "websockets"
|
||||
use_tls = bool(s.community_mqtt_use_tls)
|
||||
tls_verify = bool(s.community_mqtt_tls_verify)
|
||||
auth_mode = s.community_mqtt_auth_mode or "token"
|
||||
secure_connection = use_tls and tls_verify
|
||||
|
||||
tls_context: ssl.SSLContext | None = None
|
||||
if use_tls:
|
||||
tls_context = ssl.create_default_context()
|
||||
if not tls_verify:
|
||||
tls_context.check_hostname = False
|
||||
tls_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
status_topic = _build_status_topic(s, pubkey_hex)
|
||||
offline_payload = json.dumps(
|
||||
{
|
||||
"status": "offline",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": pubkey_hex,
|
||||
}
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"hostname": broker_host,
|
||||
"port": broker_port,
|
||||
"transport": transport,
|
||||
"tls_context": tls_context,
|
||||
"will": aiomqtt.Will(status_topic, offline_payload, retain=True),
|
||||
}
|
||||
if auth_mode == "token":
|
||||
assert private_key is not None
|
||||
token_audience = (s.community_mqtt_token_audience or "").strip() or broker_host
|
||||
jwt_token = _generate_jwt_token(
|
||||
private_key,
|
||||
public_key,
|
||||
audience=token_audience,
|
||||
email=(s.community_mqtt_email or "") if secure_connection else "",
|
||||
)
|
||||
kwargs["username"] = f"v1_{pubkey_hex}"
|
||||
kwargs["password"] = jwt_token
|
||||
elif auth_mode == "password":
|
||||
kwargs["username"] = s.community_mqtt_username or None
|
||||
kwargs["password"] = s.community_mqtt_password or None
|
||||
if transport == "websockets":
|
||||
kwargs["websocket_path"] = "/"
|
||||
return kwargs
|
||||
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
s: CommunityMqttSettings = settings # type: ignore[assignment]
|
||||
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
|
||||
|
||||
async def _fetch_device_info(self) -> dict[str, str]:
|
||||
"""Fetch firmware model/version from the radio (cached for the connection)."""
|
||||
if self._cached_device_info is not None:
|
||||
return self._cached_device_info
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
fallback = {"model": "unknown", "firmware_version": "unknown"}
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"community_stats_device_info", blocking=False
|
||||
) as mc:
|
||||
event = await mc.commands.send_device_query()
|
||||
from meshcore.events import EventType
|
||||
|
||||
if event.type == EventType.DEVICE_INFO:
|
||||
fw_ver = event.payload.get("fw ver", 0)
|
||||
if fw_ver >= 3:
|
||||
model = event.payload.get("model", "unknown") or "unknown"
|
||||
ver = event.payload.get("ver", "unknown") or "unknown"
|
||||
fw_build = event.payload.get("fw_build", "") or ""
|
||||
fw_str = f"v{ver} (Build: {fw_build})" if fw_build else f"v{ver}"
|
||||
self._cached_device_info = {
|
||||
"model": model,
|
||||
"firmware_version": fw_str,
|
||||
}
|
||||
else:
|
||||
# Old firmware — cache what we can
|
||||
self._cached_device_info = {
|
||||
"model": "unknown",
|
||||
"firmware_version": f"v{fw_ver}" if fw_ver else "unknown",
|
||||
}
|
||||
return self._cached_device_info
|
||||
except (RadioOperationBusyError, RadioDisconnectedError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Community MQTT: device info fetch failed: %s", e)
|
||||
|
||||
# Don't cache transient failures — allow retry on next status publish
|
||||
return fallback
|
||||
|
||||
async def _fetch_stats(self) -> dict[str, Any] | None:
|
||||
"""Fetch core + radio stats from the radio (best-effort, cached)."""
|
||||
if self._stats_supported is False:
|
||||
return self._cached_stats
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
now - self._last_stats_fetch
|
||||
) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None:
|
||||
return self._cached_stats
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc:
|
||||
from meshcore.events import EventType
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
core_event = await mc.commands.get_stats_core()
|
||||
if core_event.type == EventType.ERROR:
|
||||
logger.info("Community MQTT: firmware does not support stats commands")
|
||||
self._stats_supported = False
|
||||
return self._cached_stats
|
||||
if core_event.type == EventType.STATS_CORE:
|
||||
result.update(core_event.payload)
|
||||
|
||||
radio_event = await mc.commands.get_stats_radio()
|
||||
if radio_event.type == EventType.ERROR:
|
||||
logger.info("Community MQTT: firmware does not support stats commands")
|
||||
self._stats_supported = False
|
||||
return self._cached_stats
|
||||
if radio_event.type == EventType.STATS_RADIO:
|
||||
result.update(radio_event.payload)
|
||||
|
||||
if result:
|
||||
self._cached_stats = result
|
||||
self._last_stats_fetch = now
|
||||
return self._cached_stats
|
||||
|
||||
except (RadioOperationBusyError, RadioDisconnectedError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Community MQTT: stats fetch failed: %s", e)
|
||||
|
||||
return self._cached_stats
|
||||
|
||||
async def _publish_status(
|
||||
self, settings: CommunityMqttSettings, *, refresh_stats: bool = True
|
||||
) -> None:
|
||||
"""Build and publish the enriched retained status message."""
|
||||
from app.keystore import get_public_key
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
return
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
device_info = await self._fetch_device_info()
|
||||
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
||||
|
||||
status_topic = _build_status_topic(settings, pubkey_hex)
|
||||
payload: dict[str, Any] = {
|
||||
"status": "online",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": pubkey_hex,
|
||||
"model": device_info.get("model", "unknown"),
|
||||
"firmware_version": device_info.get("firmware_version", "unknown"),
|
||||
"radio": _build_radio_info(),
|
||||
"client_version": _get_client_version(),
|
||||
}
|
||||
if stats:
|
||||
payload["stats"] = stats
|
||||
|
||||
await self.publish(status_topic, payload, retain=True)
|
||||
self._last_status_publish = time.monotonic()
|
||||
|
||||
async def _on_connected_async(self, settings: object) -> None:
|
||||
"""Publish a retained online status message after connecting."""
|
||||
await self._publish_status(settings) # type: ignore[arg-type]
|
||||
|
||||
async def _on_periodic_wake(self, elapsed: float) -> None:
|
||||
if not self._settings:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if (now - self._last_status_publish) >= _STATS_REFRESH_INTERVAL:
|
||||
await self._publish_status(self._settings, refresh_stats=True)
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return (
|
||||
"Community MQTT connection failure",
|
||||
"Check your internet connection or try again later.",
|
||||
)
|
||||
|
||||
def _should_break_wait(self, elapsed: float) -> bool:
|
||||
if not self.connected:
|
||||
logger.info("Community MQTT publish failure detected, reconnecting")
|
||||
return True
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
auth_mode = getattr(s, "community_mqtt_auth_mode", "token") if s else "token"
|
||||
if auth_mode == "token" and elapsed >= _TOKEN_RENEWAL_THRESHOLD:
|
||||
logger.info("Community MQTT JWT nearing expiry, reconnecting")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _pre_connect(self, settings: object) -> bool:
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
|
||||
s: CommunityMqttSettings = settings # type: ignore[assignment]
|
||||
auth_mode = s.community_mqtt_auth_mode or "token"
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
if public_key is None or (auth_mode == "token" and private_key is None):
|
||||
# Keys not available yet, wait for settings change or key export
|
||||
self.connected = False
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,340 @@
|
||||
"""FanoutManager: owns all active fanout modules and dispatches events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DISPATCH_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# Type string -> module class mapping
|
||||
_MODULE_TYPES: dict[str, type] = {}
|
||||
|
||||
|
||||
def _format_error_detail(exc: Exception) -> str:
|
||||
"""Return a short operator-facing error string."""
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
return f"{type(exc).__name__}: {message}"
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def _register_module_types() -> None:
|
||||
"""Lazily populate the type registry to avoid circular imports."""
|
||||
if _MODULE_TYPES:
|
||||
return
|
||||
from app.fanout.apprise_mod import AppriseModule
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.map_upload import MapUploadModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
from app.fanout.sqs import SqsModule
|
||||
from app.fanout.webhook import WebhookModule
|
||||
|
||||
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
||||
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
|
||||
_MODULE_TYPES["bot"] = BotModule
|
||||
_MODULE_TYPES["webhook"] = WebhookModule
|
||||
_MODULE_TYPES["apprise"] = AppriseModule
|
||||
_MODULE_TYPES["sqs"] = SqsModule
|
||||
_MODULE_TYPES["map_upload"] = MapUploadModule
|
||||
|
||||
|
||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||
"""Check a single filter value (channels or contacts) against a key.
|
||||
|
||||
Supported shapes:
|
||||
"all" -> True
|
||||
"none" -> False
|
||||
["key1", "key2"] -> key in list (only listed)
|
||||
{"except": ["key1", "key2"]} -> key not in list (all except listed)
|
||||
"""
|
||||
if filter_value == "all":
|
||||
return True
|
||||
if filter_value == "none":
|
||||
return False
|
||||
if isinstance(filter_value, list):
|
||||
return key in filter_value
|
||||
if isinstance(filter_value, dict) and "except" in filter_value:
|
||||
return key not in filter_value["except"]
|
||||
return False
|
||||
|
||||
|
||||
def _scope_matches_message(scope: dict, data: dict) -> bool:
|
||||
"""Check whether a message event matches the given scope."""
|
||||
messages = scope.get("messages", "none")
|
||||
if messages == "all":
|
||||
return True
|
||||
if messages == "none":
|
||||
return False
|
||||
if isinstance(messages, dict):
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
if msg_type == "CHAN":
|
||||
return _matches_filter(messages.get("channels", "none"), conversation_key)
|
||||
elif msg_type == "PRIV":
|
||||
return _matches_filter(messages.get("contacts", "none"), conversation_key)
|
||||
return False
|
||||
|
||||
|
||||
def _scope_matches_raw(scope: dict, _data: dict) -> bool:
|
||||
"""Check whether a raw packet event matches the given scope."""
|
||||
return scope.get("raw_packets", "none") == "all"
|
||||
|
||||
|
||||
class FanoutManager:
|
||||
"""Owns all active fanout modules and dispatches events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||
self._bots_disabled_until_restart = False
|
||||
self._module_errors: dict[str, str] = {}
|
||||
|
||||
def _broadcast_health_update(self) -> None:
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
def _set_module_error(self, config_id: str, error: str) -> None:
|
||||
if self._module_errors.get(config_id) == error:
|
||||
return
|
||||
self._module_errors[config_id] = error
|
||||
self._broadcast_health_update()
|
||||
|
||||
def _clear_module_error(self, config_id: str) -> None:
|
||||
if self._module_errors.pop(config_id, None) is not None:
|
||||
self._broadcast_health_update()
|
||||
|
||||
def get_bots_disabled_source(self) -> str | None:
|
||||
"""Return why bot modules are unavailable, if at all."""
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
return "env"
|
||||
if self._bots_disabled_until_restart:
|
||||
return "until_restart"
|
||||
return None
|
||||
|
||||
def bots_disabled_effective(self) -> bool:
|
||||
"""Return True when bot modules should be treated as unavailable."""
|
||||
return self.get_bots_disabled_source() is not None
|
||||
|
||||
async def load_from_db(self) -> None:
|
||||
"""Read enabled fanout_configs and instantiate modules."""
|
||||
_register_module_types()
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
configs = await FanoutConfigRepository.get_enabled()
|
||||
for cfg in configs:
|
||||
await self._start_module(cfg)
|
||||
|
||||
async def _start_module(self, cfg: dict[str, Any]) -> None:
|
||||
"""Instantiate and start a single module from a config dict."""
|
||||
config_id = cfg["id"]
|
||||
config_type = cfg["type"]
|
||||
config_blob = cfg["config"]
|
||||
scope = cfg["scope"]
|
||||
|
||||
# Skip bot modules when bots are disabled server-wide or until restart.
|
||||
if config_type == "bot" and self.bots_disabled_effective():
|
||||
logger.info(
|
||||
"Skipping bot module %s (bots disabled: %s)",
|
||||
config_id,
|
||||
self.get_bots_disabled_source(),
|
||||
)
|
||||
return
|
||||
|
||||
cls = _MODULE_TYPES.get(config_type)
|
||||
if cls is None:
|
||||
logger.warning("Unknown fanout type %r for config %s, skipping", config_type, config_id)
|
||||
return
|
||||
|
||||
try:
|
||||
module = cls(config_id, config_blob, name=cfg.get("name", ""))
|
||||
await module.start()
|
||||
self._modules[config_id] = (module, scope)
|
||||
self._clear_module_error(config_id)
|
||||
logger.info(
|
||||
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to start fanout module %s", config_id)
|
||||
self._set_module_error(config_id, _format_error_detail(exc))
|
||||
|
||||
async def reload_config(self, config_id: str) -> None:
|
||||
"""Stop old module (if any) and start updated config."""
|
||||
lock = self._restart_locks.setdefault(config_id, asyncio.Lock())
|
||||
async with lock:
|
||||
await self.remove_config(config_id)
|
||||
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
cfg = await FanoutConfigRepository.get(config_id)
|
||||
if cfg is None or not cfg["enabled"]:
|
||||
return
|
||||
await self._start_module(cfg)
|
||||
|
||||
async def remove_config(self, config_id: str) -> None:
|
||||
"""Stop and remove a module."""
|
||||
entry = self._modules.pop(config_id, None)
|
||||
if entry is not None:
|
||||
module, _ = entry
|
||||
try:
|
||||
await module.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping fanout module %s", config_id)
|
||||
self._clear_module_error(config_id)
|
||||
|
||||
async def _dispatch_matching(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
matcher: Any,
|
||||
handler_name: str,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
"""Dispatch to all matching modules concurrently."""
|
||||
tasks = []
|
||||
for config_id, (module, scope) in list(self._modules.items()):
|
||||
if matcher(scope, data):
|
||||
tasks.append(self._run_handler(config_id, module, handler_name, data, log_label))
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _run_handler(
|
||||
self,
|
||||
config_id: str,
|
||||
module: FanoutModule,
|
||||
handler_name: str,
|
||||
data: dict,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
"""Run one module handler with per-module exception isolation."""
|
||||
try:
|
||||
handler = getattr(module, handler_name)
|
||||
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
||||
self._clear_module_error(config_id)
|
||||
except asyncio.TimeoutError:
|
||||
timeout_error = f"{handler_name} timed out after {_DISPATCH_TIMEOUT_SECONDS:.1f}s"
|
||||
self._set_module_error(config_id, timeout_error)
|
||||
logger.error(
|
||||
"Fanout %s %s timed out after %.1fs; restarting module",
|
||||
config_id,
|
||||
log_label,
|
||||
_DISPATCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
await self._restart_module(config_id, module)
|
||||
except Exception as exc:
|
||||
self._set_module_error(config_id, _format_error_detail(exc))
|
||||
logger.exception("Fanout %s %s error", config_id, log_label)
|
||||
|
||||
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
|
||||
"""Restart a timed-out module if it is still the active instance."""
|
||||
lock = self._restart_locks.setdefault(config_id, asyncio.Lock())
|
||||
async with lock:
|
||||
entry = self._modules.get(config_id)
|
||||
if entry is None or entry[0] is not module:
|
||||
return
|
||||
try:
|
||||
await module.stop()
|
||||
await module.start()
|
||||
except Exception:
|
||||
logger.exception("Failed to restart timed-out fanout module %s", config_id)
|
||||
self._modules.pop(config_id, None)
|
||||
self._set_module_error(
|
||||
config_id,
|
||||
"Module restart failed after timeout",
|
||||
)
|
||||
|
||||
async def broadcast_message(self, data: dict) -> None:
|
||||
"""Dispatch a decoded message to modules whose scope matches."""
|
||||
await self._dispatch_matching(
|
||||
data,
|
||||
matcher=_scope_matches_message,
|
||||
handler_name="on_message",
|
||||
log_label="on_message",
|
||||
)
|
||||
|
||||
async def broadcast_raw(self, data: dict) -> None:
|
||||
"""Dispatch a raw packet to modules whose scope matches."""
|
||||
await self._dispatch_matching(
|
||||
data,
|
||||
matcher=_scope_matches_raw,
|
||||
handler_name="on_raw",
|
||||
log_label="on_raw",
|
||||
)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
"""Shutdown all modules."""
|
||||
for config_id, (module, _) in list(self._modules.items()):
|
||||
try:
|
||||
await module.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping fanout module %s", config_id)
|
||||
self._modules.clear()
|
||||
self._restart_locks.clear()
|
||||
self._module_errors.clear()
|
||||
|
||||
def get_statuses(self) -> dict[str, dict[str, str | None]]:
|
||||
"""Return status info for each active module."""
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
result: dict[str, dict[str, str | None]] = {}
|
||||
all_ids = set(_configs_cache) | set(self._modules) | set(self._module_errors)
|
||||
for config_id in all_ids:
|
||||
info = _configs_cache.get(config_id, {})
|
||||
if info.get("enabled") is False:
|
||||
continue
|
||||
|
||||
module_entry = self._modules.get(config_id)
|
||||
module = module_entry[0] if module_entry is not None else None
|
||||
last_error = module.last_error if module is not None else None
|
||||
status = module.status if module is not None else "error"
|
||||
|
||||
manager_error = self._module_errors.get(config_id)
|
||||
if manager_error is not None:
|
||||
status = "error"
|
||||
last_error = manager_error
|
||||
elif last_error is not None and status != "error":
|
||||
status = "error"
|
||||
|
||||
if module is None and last_error is None:
|
||||
continue
|
||||
|
||||
result[config_id] = {
|
||||
"name": info.get("name", config_id),
|
||||
"type": info.get("type", "unknown"),
|
||||
"status": status,
|
||||
"last_error": last_error,
|
||||
}
|
||||
return result
|
||||
|
||||
async def disable_bots_until_restart(self) -> str:
|
||||
"""Stop active bot modules and prevent them from starting again until restart."""
|
||||
source = self.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return source
|
||||
|
||||
self._bots_disabled_until_restart = True
|
||||
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
bot_ids = [
|
||||
config_id
|
||||
for config_id in list(self._modules)
|
||||
if _configs_cache.get(config_id, {}).get("type") == "bot"
|
||||
]
|
||||
for config_id in bot_ids:
|
||||
await self.remove_config(config_id)
|
||||
|
||||
return "until_restart"
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
fanout_manager = FanoutManager()
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Fanout module for uploading heard advert packets to map.meshcore.dev.
|
||||
|
||||
Mirrors the logic of the standalone map.meshcore.dev-uploader project:
|
||||
- Listens on raw RF packets via on_raw
|
||||
- Filters for ADVERT packets, only processes repeaters (role 2) and rooms (role 3)
|
||||
- Skips nodes with no valid location (lat/lon None)
|
||||
- Applies per-pubkey rate-limiting (1-hour window, matching the uploader)
|
||||
- Signs the upload request with the radio's own Ed25519 private key
|
||||
- POSTs to the map API (or logs in dry-run mode)
|
||||
|
||||
Dry-run mode (default: True) logs the full would-be payload at INFO level
|
||||
without making any HTTP requests. Disable it only after verifying the log
|
||||
output looks correct — in particular the radio params (freq/bw/sf/cr) and
|
||||
the raw hex link.
|
||||
|
||||
Config keys
|
||||
-----------
|
||||
api_url : str, default ""
|
||||
Upload endpoint. Empty string falls back to the public map.meshcore.dev API.
|
||||
dry_run : bool, default True
|
||||
When True, log the payload at INFO level instead of sending it.
|
||||
geofence_enabled : bool, default False
|
||||
When True, only upload nodes whose location falls within geofence_radius_km of
|
||||
the radio's own configured latitude/longitude (read live from the radio at upload
|
||||
time — no lat/lon is stored in this config). When the radio's lat/lon is not set
|
||||
(0, 0) or unavailable, the geofence check is silently skipped so uploads continue
|
||||
normally until coordinates are configured.
|
||||
geofence_radius_km : float, default 0.0
|
||||
Radius of the geofence in kilometres. Nodes further than this distance
|
||||
from the radio's own position are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
||||
import httpx
|
||||
|
||||
from app.decoder import parse_advertisement, parse_packet
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.keystore import ed25519_sign_expanded, get_private_key, get_public_key
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_API_URL = "https://map.meshcore.dev/api/v1/uploader/node"
|
||||
|
||||
# Re-upload guard: skip re-uploading a pubkey seen within this window (AU parity)
|
||||
_REUPLOAD_SECONDS = 3600
|
||||
|
||||
# Only upload repeaters (2) and rooms (3). Any other role — including future
|
||||
# roles not yet defined — is rejected. An allowlist is used rather than a
|
||||
# blocklist so that new roles cannot accidentally start populating the map.
|
||||
_ALLOWED_DEVICE_ROLES = {2, 3}
|
||||
|
||||
|
||||
def _get_radio_params() -> dict:
|
||||
"""Read radio frequency parameters from the connected radio's self_info.
|
||||
|
||||
The Python meshcore library returns radio_freq in MHz (e.g. 910.525) and
|
||||
radio_bw in kHz (e.g. 62.5). These are exactly the units the map API
|
||||
expects, matching what the JS reference uploader produces after its own
|
||||
/1000 division on raw integer values. No further scaling is applied here.
|
||||
"""
|
||||
try:
|
||||
mc = radio_runtime.meshcore
|
||||
if not mc:
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
info = mc.self_info
|
||||
if not isinstance(info, dict):
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
freq = info.get("radio_freq", 0) or 0
|
||||
bw = info.get("radio_bw", 0) or 0
|
||||
sf = info.get("radio_sf", 0) or 0
|
||||
cr = info.get("radio_cr", 0) or 0
|
||||
return {
|
||||
"freq": freq,
|
||||
"cr": cr,
|
||||
"sf": sf,
|
||||
"bw": bw,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("MapUpload: could not read radio params: %s", exc)
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
|
||||
|
||||
_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room"}
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return the great-circle distance in kilometres between two lat/lon points."""
|
||||
r = 6371.0
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
class MapUploadModule(FanoutModule):
|
||||
"""Uploads heard ADVERT packets to the MeshCore community map."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
# Per-pubkey rate limiting: pubkey_hex -> last_uploaded_advert_timestamp
|
||||
self._seen: dict[str, int] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(15.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
self._last_error = None
|
||||
self._seen.clear()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
self._last_error = None
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
if data.get("payload_type") != "ADVERT":
|
||||
return
|
||||
|
||||
raw_hex = data.get("data", "")
|
||||
if not raw_hex:
|
||||
return
|
||||
|
||||
try:
|
||||
raw_bytes = bytes.fromhex(raw_hex)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None:
|
||||
return
|
||||
|
||||
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
|
||||
if advert is None:
|
||||
return
|
||||
|
||||
# Advert Ed25519 signature verification is intentionally skipped.
|
||||
# The radio validates packets before passing them to RT.
|
||||
|
||||
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||
return
|
||||
|
||||
# Skip nodes with no valid location — the decoder already nulls out
|
||||
# impossible values, so None means either no location flag or bad coords.
|
||||
if advert.lat is None or advert.lon is None:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — no valid location",
|
||||
advert.public_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
pubkey = advert.public_key.lower()
|
||||
|
||||
# Rate-limit: skip if this pubkey's timestamp hasn't advanced enough
|
||||
last_seen = self._seen.get(pubkey)
|
||||
if last_seen is not None:
|
||||
if last_seen >= advert.timestamp:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — possible replay (last=%d, advert=%d)",
|
||||
pubkey[:12],
|
||||
last_seen,
|
||||
advert.timestamp,
|
||||
)
|
||||
return
|
||||
if advert.timestamp < last_seen + _REUPLOAD_SECONDS:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — within 1-hr rate-limit window (delta=%ds)",
|
||||
pubkey[:12],
|
||||
advert.timestamp - last_seen,
|
||||
)
|
||||
return
|
||||
|
||||
await self._upload(
|
||||
pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon
|
||||
)
|
||||
|
||||
async def _upload(
|
||||
self,
|
||||
pubkey: str,
|
||||
advert_timestamp: int,
|
||||
device_role: int,
|
||||
raw_hex: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
) -> None:
|
||||
# Geofence check: if enabled, skip nodes outside the configured radius.
|
||||
# The reference center is the radio's own lat/lon read live from self_info —
|
||||
# no coordinates are stored in the fanout config. If the radio lat/lon is
|
||||
# (0, 0) or unavailable the check is skipped transparently so uploads
|
||||
# continue normally until the operator sets coordinates in radio settings.
|
||||
geofence_dist_km: float | None = None
|
||||
if self.config.get("geofence_enabled"):
|
||||
try:
|
||||
mc = radio_runtime.meshcore
|
||||
sinfo = mc.self_info if mc else None
|
||||
fence_lat = float((sinfo or {}).get("adv_lat", 0) or 0)
|
||||
fence_lon = float((sinfo or {}).get("adv_lon", 0) or 0)
|
||||
except Exception as exc:
|
||||
logger.debug("MapUpload: could not read radio lat/lon for geofence: %s", exc)
|
||||
fence_lat = 0.0
|
||||
fence_lon = 0.0
|
||||
|
||||
if fence_lat == 0.0 and fence_lon == 0.0:
|
||||
logger.debug(
|
||||
"MapUpload: geofence skipped for %s — radio lat/lon not configured",
|
||||
pubkey[:12],
|
||||
)
|
||||
else:
|
||||
fence_radius_km = float(self.config.get("geofence_radius_km", 0) or 0)
|
||||
geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon)
|
||||
if geofence_dist_km > fence_radius_km:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)",
|
||||
pubkey[:12],
|
||||
geofence_dist_km,
|
||||
fence_radius_km,
|
||||
)
|
||||
return
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
|
||||
if private_key is None or public_key is None:
|
||||
logger.warning(
|
||||
"MapUpload: private key not available — cannot sign upload for %s. "
|
||||
"Ensure radio firmware has ENABLE_PRIVATE_KEY_EXPORT=1.",
|
||||
pubkey[:12],
|
||||
)
|
||||
return
|
||||
|
||||
api_url = str(self.config.get("api_url", "") or _DEFAULT_API_URL).strip()
|
||||
dry_run = bool(self.config.get("dry_run", True))
|
||||
role_name = _ROLE_NAMES.get(device_role, f"role={device_role}")
|
||||
|
||||
params = _get_radio_params()
|
||||
upload_data = {
|
||||
"params": params,
|
||||
"links": [f"meshcore://{raw_hex}"],
|
||||
}
|
||||
|
||||
# Sign: SHA-256 the compact JSON, then Ed25519-sign the hash
|
||||
json_str = json.dumps(upload_data, separators=(",", ":"))
|
||||
data_hash = hashlib.sha256(json_str.encode()).digest()
|
||||
scalar = private_key[:32]
|
||||
prefix_bytes = private_key[32:]
|
||||
signature = ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key)
|
||||
|
||||
request_payload = {
|
||||
"data": json_str,
|
||||
"signature": signature.hex(),
|
||||
"publicKey": public_key.hex(),
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
geofence_note = (
|
||||
f" | geofence: {geofence_dist_km:.2f} km from observer"
|
||||
if geofence_dist_km is not None
|
||||
else ""
|
||||
)
|
||||
logger.info(
|
||||
"MapUpload [DRY RUN] %s (%s)%s → would POST to %s\n payload: %s",
|
||||
pubkey[:12],
|
||||
role_name,
|
||||
geofence_note,
|
||||
api_url,
|
||||
json.dumps(request_payload, separators=(",", ":")),
|
||||
)
|
||||
# Still update _seen so rate-limiting works during dry-run testing
|
||||
self._seen[pubkey] = advert_timestamp
|
||||
return
|
||||
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
api_url,
|
||||
content=json.dumps(request_payload, separators=(",", ":")),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._seen[pubkey] = advert_timestamp
|
||||
self._set_last_error(None)
|
||||
logger.info(
|
||||
"MapUpload: uploaded %s (%s) → HTTP %d",
|
||||
pubkey[:12],
|
||||
role_name,
|
||||
resp.status_code,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._set_last_error(f"HTTP {exc.response.status_code}")
|
||||
logger.warning(
|
||||
"MapUpload: server returned %d for %s: %s",
|
||||
exc.response.status_code,
|
||||
pubkey[:12],
|
||||
exc.response.text[:200],
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
self._set_last_error(str(exc))
|
||||
logger.warning("MapUpload: request error for %s: %s", pubkey[:12], exc)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._client is None:
|
||||
return "disconnected"
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -0,0 +1,91 @@
|
||||
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrivateMqttSettings(Protocol):
|
||||
"""Attributes expected on the settings object for the private MQTT publisher."""
|
||||
|
||||
mqtt_broker_host: str
|
||||
mqtt_broker_port: int
|
||||
mqtt_username: str
|
||||
mqtt_password: str
|
||||
mqtt_use_tls: bool
|
||||
mqtt_tls_insecure: bool
|
||||
mqtt_publish_messages: bool
|
||||
mqtt_publish_raw_packets: bool
|
||||
|
||||
|
||||
class MqttPublisher(BaseMqttPublisher):
|
||||
"""Manages an MQTT connection and publishes mesh network events."""
|
||||
|
||||
_backoff_max = 30
|
||||
_log_prefix = "MQTT"
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if MQTT is configured and has something to publish."""
|
||||
s: PrivateMqttSettings | None = self._settings
|
||||
return bool(
|
||||
s and s.mqtt_broker_host and (s.mqtt_publish_messages or s.mqtt_publish_raw_packets)
|
||||
)
|
||||
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
s: PrivateMqttSettings = settings # type: ignore[assignment]
|
||||
return {
|
||||
"hostname": s.mqtt_broker_host,
|
||||
"port": s.mqtt_broker_port,
|
||||
"username": s.mqtt_username or None,
|
||||
"password": s.mqtt_password or None,
|
||||
"tls_context": self._build_tls_context(s),
|
||||
}
|
||||
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
s: PrivateMqttSettings = settings # type: ignore[assignment]
|
||||
return ("MQTT connected", f"{s.mqtt_broker_host}:{s.mqtt_broker_port}")
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return ("MQTT connection failure", "Please correct the settings or disable.")
|
||||
|
||||
@staticmethod
|
||||
def _build_tls_context(settings: PrivateMqttSettings) -> ssl.SSLContext | None:
|
||||
"""Build TLS context from settings, or None if TLS is disabled."""
|
||||
if not settings.mqtt_use_tls:
|
||||
return None
|
||||
ctx = ssl.create_default_context()
|
||||
if settings.mqtt_tls_insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a decrypted message."""
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "unknown")
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"{prefix}/dm:{conversation_key}"
|
||||
elif msg_type == "CHAN":
|
||||
return f"{prefix}/gm:{conversation_key}"
|
||||
return f"{prefix}/message:{conversation_key}"
|
||||
|
||||
|
||||
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a raw packet."""
|
||||
info = data.get("decrypted_info")
|
||||
if info and isinstance(info, dict):
|
||||
contact_key = info.get("contact_key")
|
||||
channel_key = info.get("channel_key")
|
||||
if contact_key:
|
||||
return f"{prefix}/raw/dm:{contact_key}"
|
||||
if channel_key:
|
||||
return f"{prefix}/raw/gm:{channel_key}"
|
||||
return f"{prefix}/raw/unrouted"
|
||||
@@ -12,22 +12,29 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from app.models import AppSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BACKOFF_MIN = 5
|
||||
|
||||
|
||||
def _format_error_detail(exc: Exception) -> str:
|
||||
"""Return a short operator-facing error string."""
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
return message
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def _broadcast_health() -> None:
|
||||
"""Push updated health (including MQTT status) to all WS clients."""
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
@@ -38,6 +45,11 @@ class BaseMqttPublisher(ABC):
|
||||
|
||||
Subclasses implement the abstract hooks to control configuration checks,
|
||||
client construction, toast messages, and optional wait-loop behavior.
|
||||
|
||||
The settings type is duck-typed — each subclass defines a Protocol
|
||||
describing the attributes it expects (e.g. ``PrivateMqttSettings``,
|
||||
``CommunityMqttSettings``). Callers pass ``SimpleNamespace`` instances
|
||||
that satisfy the protocol.
|
||||
"""
|
||||
|
||||
_backoff_max: int = 30
|
||||
@@ -47,16 +59,34 @@ class BaseMqttPublisher(ABC):
|
||||
def __init__(self) -> None:
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._settings: AppSettings | None = None
|
||||
self._settings: Any = None
|
||||
self._settings_version: int = 0
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
self.integration_name: str = ""
|
||||
self._last_error: str | None = None
|
||||
|
||||
def set_integration_name(self, name: str) -> None:
|
||||
"""Attach the configured fanout-module name for operator-facing logs."""
|
||||
self.integration_name = name.strip()
|
||||
|
||||
def _integration_label(self) -> str:
|
||||
"""Return a concise label for logs, including the configured module name."""
|
||||
if self.integration_name:
|
||||
return f"{self._log_prefix} [{self.integration_name}]"
|
||||
return self._log_prefix
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
"""Return the most recent retained connection/publish error."""
|
||||
return self._last_error
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
async def start(self, settings: AppSettings) -> None:
|
||||
async def start(self, settings: object) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._last_error = None
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
@@ -73,21 +103,30 @@ class BaseMqttPublisher(ABC):
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
self._last_error = None
|
||||
|
||||
async def restart(self, settings: AppSettings) -> None:
|
||||
async def restart(self, settings: object) -> None:
|
||||
"""Called when settings change — stop + start."""
|
||||
await self.stop()
|
||||
await self.start(settings)
|
||||
|
||||
async def publish(self, topic: str, payload: dict[str, Any]) -> None:
|
||||
async def publish(self, topic: str, payload: dict[str, Any], *, retain: bool = False) -> None:
|
||||
"""Publish a JSON payload. Drops silently if not connected."""
|
||||
if self._client is None or not self.connected:
|
||||
return
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload))
|
||||
await self._client.publish(topic, json.dumps(payload), retain=retain)
|
||||
except Exception as e:
|
||||
logger.warning("%s publish failed on %s: %s", self._log_prefix, topic, e)
|
||||
logger.warning(
|
||||
"%s publish failed on %s. This is usually transient network noise; "
|
||||
"if it self-resolves and reconnects, it is generally not a concern. Persistent errors may indicate a problem with your network connection or MQTT broker. Original error: %s",
|
||||
self._integration_label(),
|
||||
topic,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
self.connected = False
|
||||
self._last_error = _format_error_detail(e)
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
@@ -99,11 +138,11 @@ class BaseMqttPublisher(ABC):
|
||||
"""Return True when this publisher should attempt to connect."""
|
||||
|
||||
@abstractmethod
|
||||
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
|
||||
|
||||
@abstractmethod
|
||||
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
"""Return ``(title, detail)`` for the success toast on connect."""
|
||||
|
||||
@abstractmethod
|
||||
@@ -116,7 +155,7 @@ class BaseMqttPublisher(ABC):
|
||||
"""Return True to break the inner wait (e.g. token expiry)."""
|
||||
return False
|
||||
|
||||
async def _pre_connect(self, settings: AppSettings) -> bool:
|
||||
async def _pre_connect(self, settings: object) -> bool:
|
||||
"""Called before connecting. Return True to proceed, False to retry."""
|
||||
return True
|
||||
|
||||
@@ -124,6 +163,17 @@ class BaseMqttPublisher(ABC):
|
||||
"""Called each time the loop finds the publisher not configured."""
|
||||
return # no-op by default; subclasses may override
|
||||
|
||||
async def _on_connected_async(self, settings: object) -> None:
|
||||
"""Async hook called after connection succeeds (before health broadcast).
|
||||
|
||||
Subclasses can override to publish messages immediately after connecting.
|
||||
"""
|
||||
return # no-op by default
|
||||
|
||||
async def _on_periodic_wake(self, elapsed: float) -> None:
|
||||
"""Called every ~60s while connected. Subclasses may override."""
|
||||
return
|
||||
|
||||
# ── Connection loop ────────────────────────────────────────────────
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
@@ -166,10 +216,12 @@ class BaseMqttPublisher(ABC):
|
||||
async with aiomqtt.Client(**client_kwargs) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
self._last_error = None
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
title, detail = self._on_connected(settings)
|
||||
broadcast_success(title, detail)
|
||||
await self._on_connected_async(settings)
|
||||
_broadcast_health()
|
||||
|
||||
# Wait until cancelled or settings version changes.
|
||||
@@ -181,6 +233,7 @@ class BaseMqttPublisher(ABC):
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = time.monotonic() - connect_time
|
||||
await self._on_periodic_wake(elapsed)
|
||||
if self._should_break_wait(elapsed):
|
||||
break
|
||||
continue
|
||||
@@ -198,15 +251,39 @@ class BaseMqttPublisher(ABC):
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
self._last_error = _format_error_detail(e)
|
||||
|
||||
# Windows ProactorEventLoop does not implement add_reader /
|
||||
# add_writer, which paho-mqtt requires. Give a specific,
|
||||
# actionable toast instead of the generic connection error.
|
||||
if isinstance(e, NotImplementedError) and sys.platform == "win32":
|
||||
broadcast_error(
|
||||
"MQTT unavailable — Windows event loop incompatible",
|
||||
"The default Windows event loop (ProactorEventLoop) does "
|
||||
"not support MQTT. Restart with: uv run uvicorn "
|
||||
"app.main:app --loop none",
|
||||
)
|
||||
_broadcast_health()
|
||||
logger.error(
|
||||
"%s cannot run: Windows ProactorEventLoop does not "
|
||||
"implement add_reader/add_writer required by paho-mqtt. "
|
||||
"Restart uvicorn with '--loop none' to use "
|
||||
"SelectorEventLoop instead. Giving up (will not retry).",
|
||||
self._integration_label(),
|
||||
)
|
||||
return
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
logger.warning(
|
||||
"%s connection error: %s (reconnecting in %ds)",
|
||||
self._log_prefix,
|
||||
"%s connection error. This is usually transient network noise; "
|
||||
"if it self-resolves, it is generally not a concern: %s "
|
||||
"(reconnecting in %ds). If this error persists, check your network connection and MQTT broker status.",
|
||||
self._integration_label(),
|
||||
e,
|
||||
backoff,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Fanout module wrapping the community MQTT publisher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.community_mqtt import CommunityMqttPublisher, _format_raw_packet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_PACKET_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
_TOPIC_TEMPLATE_FIELD_CANONICAL = {
|
||||
"iata": "IATA",
|
||||
"public_key": "PUBLIC_KEY",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_topic_template(topic_template: str) -> str:
|
||||
"""Normalize packet topic template fields to canonical uppercase placeholders."""
|
||||
template = topic_template.strip() or _DEFAULT_PACKET_TOPIC_TEMPLATE
|
||||
parts: list[str] = []
|
||||
try:
|
||||
parsed = string.Formatter().parse(template)
|
||||
for literal_text, field_name, format_spec, conversion in parsed:
|
||||
parts.append(literal_text)
|
||||
if field_name is None:
|
||||
continue
|
||||
normalized_field = _TOPIC_TEMPLATE_FIELD_CANONICAL.get(field_name.lower())
|
||||
if normalized_field is None:
|
||||
raise ValueError(f"Unsupported topic template field(s): {field_name}")
|
||||
replacement = ["{", normalized_field]
|
||||
if conversion:
|
||||
replacement.extend(["!", conversion])
|
||||
if format_spec:
|
||||
replacement.extend([":", format_spec])
|
||||
replacement.append("}")
|
||||
parts.append("".join(replacement))
|
||||
except ValueError:
|
||||
raise
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _config_to_settings(config: dict) -> SimpleNamespace:
|
||||
"""Map a fanout config blob to a settings namespace for the CommunityMqttPublisher."""
|
||||
return SimpleNamespace(
|
||||
community_mqtt_enabled=True,
|
||||
community_mqtt_broker_host=config.get("broker_host", "mqtt-us-v1.letsmesh.net"),
|
||||
community_mqtt_broker_port=config.get("broker_port", 443),
|
||||
community_mqtt_transport=config.get("transport", "websockets"),
|
||||
community_mqtt_use_tls=config.get("use_tls", True),
|
||||
community_mqtt_tls_verify=config.get("tls_verify", True),
|
||||
community_mqtt_auth_mode=config.get("auth_mode", "token"),
|
||||
community_mqtt_username=config.get("username", ""),
|
||||
community_mqtt_password=config.get("password", ""),
|
||||
community_mqtt_iata=config.get("iata", ""),
|
||||
community_mqtt_email=config.get("email", ""),
|
||||
community_mqtt_token_audience=config.get("token_audience", ""),
|
||||
)
|
||||
|
||||
|
||||
def _render_packet_topic(topic_template: str, *, iata: str, public_key: str) -> str:
|
||||
"""Render the configured raw-packet publish topic."""
|
||||
template = _normalize_topic_template(topic_template)
|
||||
return template.format(IATA=iata, PUBLIC_KEY=public_key)
|
||||
|
||||
|
||||
class MqttCommunityModule(FanoutModule):
|
||||
"""Wraps a CommunityMqttPublisher for community packet sharing."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._publisher = CommunityMqttPublisher()
|
||||
self._publisher.set_integration_name(name or config_id)
|
||||
|
||||
async def start(self) -> None:
|
||||
settings = _config_to_settings(self.config)
|
||||
await self._publisher.start(settings)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._publisher.stop()
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
# Community MQTT only publishes raw packets, not decoded messages.
|
||||
pass
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
if not self._publisher.connected or self._publisher._settings is None:
|
||||
return
|
||||
await _publish_community_packet(self._publisher, self.config, data)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._publisher._is_configured():
|
||||
if self._publisher.last_error:
|
||||
return "error"
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
return "disconnected"
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
return self._publisher.last_error
|
||||
|
||||
|
||||
async def _publish_community_packet(
|
||||
publisher: CommunityMqttPublisher,
|
||||
config: dict,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Format and publish a raw packet to the community broker."""
|
||||
try:
|
||||
from app.keystore import get_public_key
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
return
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
packet = _format_raw_packet(data, device_name, pubkey_hex)
|
||||
iata = config.get("iata", "").upper().strip()
|
||||
if not _IATA_RE.fullmatch(iata):
|
||||
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
|
||||
return
|
||||
topic = _render_packet_topic(
|
||||
str(config.get("topic_template", _DEFAULT_PACKET_TOPIC_TEMPLATE)),
|
||||
iata=iata,
|
||||
public_key=pubkey_hex,
|
||||
)
|
||||
|
||||
await publisher.publish(topic, packet)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Community MQTT broadcast error: %s", e, exc_info=True)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Fanout module wrapping the private MQTT publisher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _config_to_settings(config: dict) -> SimpleNamespace:
|
||||
"""Map a fanout config blob to a settings namespace for the MqttPublisher."""
|
||||
return SimpleNamespace(
|
||||
mqtt_broker_host=config.get("broker_host", ""),
|
||||
mqtt_broker_port=config.get("broker_port", 1883),
|
||||
mqtt_username=config.get("username", ""),
|
||||
mqtt_password=config.get("password", ""),
|
||||
mqtt_use_tls=config.get("use_tls", False),
|
||||
mqtt_tls_insecure=config.get("tls_insecure", False),
|
||||
mqtt_topic_prefix=config.get("topic_prefix", "meshcore"),
|
||||
mqtt_publish_messages=True,
|
||||
mqtt_publish_raw_packets=True,
|
||||
)
|
||||
|
||||
|
||||
class MqttPrivateModule(FanoutModule):
|
||||
"""Wraps an MqttPublisher instance for private MQTT forwarding."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._publisher = MqttPublisher()
|
||||
self._publisher.set_integration_name(name or config_id)
|
||||
|
||||
async def start(self) -> None:
|
||||
settings = _config_to_settings(self.config)
|
||||
await self._publisher.start(settings)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._publisher.stop()
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
if not self._publisher.connected or self._publisher._settings is None:
|
||||
return
|
||||
prefix = self.config.get("topic_prefix", "meshcore")
|
||||
topic = _build_message_topic(prefix, data)
|
||||
await self._publisher.publish(topic, data)
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
if not self._publisher.connected or self._publisher._settings is None:
|
||||
return
|
||||
prefix = self.config.get("topic_prefix", "meshcore")
|
||||
topic = _build_raw_packet_topic(prefix, data)
|
||||
await self._publisher.publish(topic, data)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("broker_host"):
|
||||
return "disconnected"
|
||||
if self._publisher.last_error:
|
||||
return "error"
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
return self._publisher.last_error
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Fanout module for Amazon SQS delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from functools import partial
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_payload(data: dict, *, event_type: str) -> str:
|
||||
"""Serialize a fanout event into a stable JSON envelope."""
|
||||
return json.dumps(
|
||||
{
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def _infer_region_from_queue_url(queue_url: str) -> str | None:
|
||||
"""Infer AWS region from a standard SQS queue URL host when possible."""
|
||||
host = urlparse(queue_url).hostname or ""
|
||||
if not host:
|
||||
return None
|
||||
|
||||
parts = host.split(".")
|
||||
if len(parts) < 4 or parts[0] != "sqs":
|
||||
return None
|
||||
if parts[2] != "amazonaws":
|
||||
return None
|
||||
if parts[3] not in {"com", "com.cn"}:
|
||||
return None
|
||||
|
||||
region = parts[1].strip()
|
||||
return region or None
|
||||
|
||||
|
||||
def _is_fifo_queue(queue_url: str) -> bool:
|
||||
"""Return True when the configured queue URL points at an SQS FIFO queue."""
|
||||
return queue_url.rstrip("/").endswith(".fifo")
|
||||
|
||||
|
||||
def _build_message_group_id(data: dict, *, event_type: str) -> str:
|
||||
"""Choose a stable FIFO group ID from the event identity."""
|
||||
if event_type == "message":
|
||||
conversation_key = str(data.get("conversation_key", "")).strip()
|
||||
if conversation_key:
|
||||
return f"message-{conversation_key}"
|
||||
return "message-default"
|
||||
return "raw-packets"
|
||||
|
||||
|
||||
def _build_message_deduplication_id(data: dict, *, event_type: str, body: str) -> str:
|
||||
"""Choose a deterministic deduplication ID for FIFO queues."""
|
||||
if event_type == "message":
|
||||
message_id = data.get("id")
|
||||
if isinstance(message_id, int):
|
||||
return f"message-{message_id}"
|
||||
else:
|
||||
observation_id = data.get("observation_id")
|
||||
if isinstance(observation_id, str) and observation_id.strip():
|
||||
return f"raw-{observation_id}"
|
||||
packet_id = data.get("id")
|
||||
if isinstance(packet_id, int):
|
||||
return f"raw-{packet_id}"
|
||||
return hashlib.sha256(body.encode()).hexdigest()
|
||||
|
||||
|
||||
class SqsModule(FanoutModule):
|
||||
"""Delivers message and raw-packet events to an Amazon SQS queue."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client = None
|
||||
|
||||
async def start(self) -> None:
|
||||
kwargs: dict[str, str] = {}
|
||||
queue_url = str(self.config.get("queue_url", "")).strip()
|
||||
region_name = str(self.config.get("region_name", "")).strip()
|
||||
endpoint_url = str(self.config.get("endpoint_url", "")).strip()
|
||||
access_key_id = str(self.config.get("access_key_id", "")).strip()
|
||||
secret_access_key = str(self.config.get("secret_access_key", "")).strip()
|
||||
session_token = str(self.config.get("session_token", "")).strip()
|
||||
|
||||
if not region_name:
|
||||
region_name = _infer_region_from_queue_url(queue_url) or ""
|
||||
if region_name:
|
||||
kwargs["region_name"] = region_name
|
||||
if endpoint_url:
|
||||
kwargs["endpoint_url"] = endpoint_url
|
||||
if access_key_id and secret_access_key:
|
||||
kwargs["aws_access_key_id"] = access_key_id
|
||||
kwargs["aws_secret_access_key"] = secret_access_key
|
||||
if session_token:
|
||||
kwargs["aws_session_token"] = session_token
|
||||
|
||||
self._client = boto3.client("sqs", **kwargs)
|
||||
self._last_error = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._client = None
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
await self._send(data, event_type="message")
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
await self._send(data, event_type="raw_packet")
|
||||
|
||||
async def _send(self, data: dict, *, event_type: str) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
queue_url = str(self.config.get("queue_url", "")).strip()
|
||||
if not queue_url:
|
||||
return
|
||||
|
||||
body = _build_payload(data, event_type=event_type)
|
||||
request_kwargs: dict[str, object] = {
|
||||
"QueueUrl": queue_url,
|
||||
"MessageBody": body,
|
||||
"MessageAttributes": {
|
||||
"event_type": {
|
||||
"DataType": "String",
|
||||
"StringValue": event_type,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if _is_fifo_queue(queue_url):
|
||||
request_kwargs["MessageGroupId"] = _build_message_group_id(data, event_type=event_type)
|
||||
request_kwargs["MessageDeduplicationId"] = _build_message_deduplication_id(
|
||||
data, event_type=event_type, body=body
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
|
||||
self._set_last_error(None)
|
||||
except (ClientError, BotoCoreError) as exc:
|
||||
self._set_last_error(str(exc))
|
||||
logger.warning("SQS %s send error: %s", self.config_id, exc)
|
||||
except Exception as exc:
|
||||
self._set_last_error(str(exc))
|
||||
logger.exception("Unexpected SQS send error for %s", self.config_id)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not str(self.config.get("queue_url", "")).strip():
|
||||
return "disconnected"
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Fanout module for webhook (HTTP POST) delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookModule(FanoutModule):
|
||||
"""Delivers message data to an HTTP endpoint via POST (or configurable method)."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
||||
self._last_error = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
await self._send(data, event_type="message")
|
||||
|
||||
async def _send(self, data: dict, *, event_type: str) -> None:
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
url = self.config.get("url", "")
|
||||
if not url:
|
||||
return
|
||||
|
||||
method = self.config.get("method", "POST").upper()
|
||||
extra_headers = self.config.get("headers", {})
|
||||
hmac_secret = self.config.get("hmac_secret", "")
|
||||
hmac_header = self.config.get("hmac_header", "X-Webhook-Signature")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": event_type,
|
||||
**extra_headers,
|
||||
}
|
||||
|
||||
body_bytes = json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
|
||||
|
||||
if hmac_secret:
|
||||
sig = hmac.new(hmac_secret.encode(), body_bytes, hashlib.sha256).hexdigest()
|
||||
headers[hmac_header or "X-Webhook-Signature"] = f"sha256={sig}"
|
||||
|
||||
try:
|
||||
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
|
||||
resp.raise_for_status()
|
||||
self._set_last_error(None)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._set_last_error(f"HTTP {exc.response.status_code}")
|
||||
logger.warning(
|
||||
"Webhook %s returned %s for %s",
|
||||
self.config_id,
|
||||
exc.response.status_code,
|
||||
url,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
self._set_last_error(str(exc))
|
||||
logger.warning("Webhook %s request error: %s", self.config_id, exc)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("url"):
|
||||
return "disconnected"
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
+138
-47
@@ -7,9 +7,48 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_CACHE_CONTROL = "no-store"
|
||||
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
||||
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
|
||||
FRONTEND_BUILD_INSTRUCTIONS = (
|
||||
"Run 'cd frontend && npm install && npm run build', "
|
||||
"or use a release zip that includes frontend/prebuilt."
|
||||
)
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
|
||||
class CacheControlStaticFiles(StaticFiles):
|
||||
"""StaticFiles variant that adds a fixed Cache-Control header."""
|
||||
|
||||
def __init__(self, *args, cache_control: str, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.cache_control = cache_control
|
||||
|
||||
def file_response(self, *args, **kwargs):
|
||||
response = super().file_response(*args, **kwargs)
|
||||
response.headers["Cache-Control"] = self.cache_control
|
||||
return response
|
||||
|
||||
|
||||
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
|
||||
return FileResponse(path, headers={"Cache-Control": cache_control})
|
||||
|
||||
|
||||
def _is_index_file(path: Path, index_file: Path) -> bool:
|
||||
"""Return True when the requested file is the SPA shell index.html."""
|
||||
return path == index_file
|
||||
|
||||
|
||||
def _resolve_request_base(request: Request) -> str:
|
||||
"""Resolve the external base URL, honoring common reverse-proxy headers.
|
||||
|
||||
Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash)
|
||||
so callers can append paths directly.
|
||||
|
||||
Recognized headers:
|
||||
- ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host.
|
||||
- ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added
|
||||
by the proxy (e.g. ``/meshcore``).
|
||||
"""
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
|
||||
@@ -17,47 +56,60 @@ def _resolve_request_origin(request: Request) -> str:
|
||||
proto = forwarded_proto.split(",")[0].strip()
|
||||
host = forwarded_host.split(",")[0].strip()
|
||||
if proto and host:
|
||||
return f"{proto}://{host}"
|
||||
origin = f"{proto}://{host}"
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
|
||||
return str(request.base_url).rstrip("/")
|
||||
# Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy
|
||||
prefix = (
|
||||
(request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "")
|
||||
.strip()
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
return f"{origin}{prefix}/"
|
||||
|
||||
|
||||
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
|
||||
"""Resolve and validate a built frontend directory."""
|
||||
frontend_dir = frontend_dir.resolve()
|
||||
index_file = frontend_dir / "index.html"
|
||||
|
||||
if not frontend_dir.exists():
|
||||
if log_failures:
|
||||
logger.error("Frontend build directory not found at %s.", frontend_dir)
|
||||
return False, frontend_dir
|
||||
|
||||
if not frontend_dir.is_dir():
|
||||
if log_failures:
|
||||
logger.error("Frontend build path is not a directory: %s.", frontend_dir)
|
||||
return False, frontend_dir
|
||||
|
||||
if not index_file.exists():
|
||||
if log_failures:
|
||||
logger.error("Frontend index file not found at %s.", index_file)
|
||||
return False, frontend_dir
|
||||
|
||||
return True, frontend_dir
|
||||
|
||||
|
||||
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"""Register frontend static file routes if a built frontend is available.
|
||||
"""Register frontend static file routes if a built frontend is available."""
|
||||
valid, frontend_dir = _validate_frontend_dir(frontend_dir)
|
||||
if not valid:
|
||||
return False
|
||||
|
||||
Returns True when routes are registered, False when frontend files are
|
||||
missing/incomplete. Missing frontend files are logged but are not fatal.
|
||||
"""
|
||||
frontend_dir = frontend_dir.resolve()
|
||||
index_file = frontend_dir / "index.html"
|
||||
assets_dir = frontend_dir / "assets"
|
||||
|
||||
if not frontend_dir.exists():
|
||||
logger.error(
|
||||
"Frontend build directory not found at %s. "
|
||||
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
|
||||
frontend_dir,
|
||||
)
|
||||
return False
|
||||
|
||||
if not frontend_dir.is_dir():
|
||||
logger.error(
|
||||
"Frontend build path is not a directory: %s. "
|
||||
"API will continue without frontend routes.",
|
||||
frontend_dir,
|
||||
)
|
||||
return False
|
||||
|
||||
if not index_file.exists():
|
||||
logger.error(
|
||||
"Frontend index file not found at %s. "
|
||||
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
|
||||
index_file,
|
||||
)
|
||||
return False
|
||||
|
||||
if assets_dir.exists() and assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
app.mount(
|
||||
"/assets",
|
||||
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
|
||||
name="assets",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Frontend assets directory missing at %s; /assets files will not be served",
|
||||
@@ -67,31 +119,31 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
"""Serve the frontend index.html."""
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
"""Serve a dynamic web manifest using the active request origin."""
|
||||
origin = _resolve_request_origin(request)
|
||||
"""Serve a dynamic web manifest using the active request base URL."""
|
||||
base = _resolve_request_base(request)
|
||||
manifest = {
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"id": f"{origin}/",
|
||||
"start_url": f"{origin}/",
|
||||
"scope": f"{origin}/",
|
||||
"id": base,
|
||||
"start_url": base,
|
||||
"scope": base,
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
|
||||
"theme_color": "#111419",
|
||||
"background_color": "#111419",
|
||||
"icons": [
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-192x192.png",
|
||||
"src": f"{base}web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-512x512.png",
|
||||
"src": f"{base}web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
@@ -107,6 +159,18 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/{path:path}")
|
||||
async def serve_frontend(path: str):
|
||||
"""Serve frontend files, falling back to index.html for SPA routing."""
|
||||
if path == "api" or path.startswith("api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": (
|
||||
"API endpoint not found. If you are seeing this in response to a "
|
||||
"frontend request, you may be running a newer frontend with an older "
|
||||
"backend or vice versa. A full update is suggested."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
file_path = (frontend_dir / path).resolve()
|
||||
try:
|
||||
file_path.relative_to(frontend_dir)
|
||||
@@ -114,14 +178,43 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
raise HTTPException(status_code=404, detail="Not found") from None
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
cache_control = (
|
||||
INDEX_CACHE_CONTROL
|
||||
if _is_index_file(file_path, index_file)
|
||||
else STATIC_FILE_CACHE_CONTROL
|
||||
)
|
||||
return _file_response(file_path, cache_control=cache_control)
|
||||
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
logger.info("Serving frontend from %s", frontend_dir)
|
||||
return True
|
||||
|
||||
|
||||
def register_first_available_frontend_static_routes(
|
||||
app: FastAPI, frontend_dirs: list[Path]
|
||||
) -> Path | None:
|
||||
"""Register frontend routes from the first valid build directory."""
|
||||
for i, candidate in enumerate(frontend_dirs):
|
||||
valid, resolved_candidate = _validate_frontend_dir(candidate, log_failures=False)
|
||||
if not valid:
|
||||
continue
|
||||
|
||||
if register_frontend_static_routes(app, resolved_candidate):
|
||||
logger.info("Selected frontend build directory %s", resolved_candidate)
|
||||
return resolved_candidate
|
||||
|
||||
if i < len(frontend_dirs) - 1:
|
||||
logger.warning("Frontend build at %s was unusable; trying fallback", resolved_candidate)
|
||||
|
||||
logger.error(
|
||||
"No usable frontend build found. Searched: %s. %s API will continue without frontend routes.",
|
||||
", ".join(str(path.resolve()) for path in frontend_dirs),
|
||||
FRONTEND_BUILD_INSTRUCTIONS,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
"""Register a fallback route that tells the user to build the frontend."""
|
||||
|
||||
@@ -129,7 +222,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
async def frontend_not_built():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
|
||||
},
|
||||
content={"detail": f"Frontend not built. {FRONTEND_BUILD_INSTRUCTIONS}"},
|
||||
)
|
||||
|
||||
+34
-1
@@ -1,14 +1,18 @@
|
||||
"""
|
||||
Ephemeral keystore for storing sensitive keys in memory.
|
||||
Ephemeral keystore for storing sensitive keys in memory, plus the Ed25519
|
||||
signing primitive used by fanout modules that need to sign requests with the
|
||||
radio's own key.
|
||||
|
||||
The private key is stored in memory only and is never persisted to disk.
|
||||
It's exported from the radio on startup and reconnect, then used for
|
||||
server-side decryption of direct messages.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import nacl.bindings
|
||||
from meshcore import EventType
|
||||
|
||||
from app.decoder import derive_public_key
|
||||
@@ -25,11 +29,40 @@ NO_EVENT_RECEIVED_GUIDANCE = (
|
||||
"issue commands to the radio."
|
||||
)
|
||||
|
||||
# Ed25519 group order (L) — used in the expanded signing primitive below
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
# In-memory storage for the private key and derived public key
|
||||
_private_key: bytes | None = None
|
||||
_public_key: bytes | None = None
|
||||
|
||||
|
||||
def ed25519_sign_expanded(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard
|
||||
Ed25519 libraries expect seed format and would re-SHA-512 the key, so we
|
||||
perform the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def clear_keys() -> None:
|
||||
"""Clear any stored private/public key material from memory."""
|
||||
global _private_key, _public_key
|
||||
had_key = _private_key is not None or _public_key is not None
|
||||
_private_key = None
|
||||
_public_key = None
|
||||
if had_key:
|
||||
logger.info("Cleared in-memory keystore")
|
||||
|
||||
|
||||
def set_private_key(key: bytes) -> None:
|
||||
"""Store the private key in memory and derive the public key.
|
||||
|
||||
|
||||
+176
-35
@@ -1,38 +1,182 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows event-loop fix for MQTT fanout (aiomqtt / paho-mqtt) compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
# On Windows, uvicorn's default "auto" loop explicitly creates ProactorEventLoop,
|
||||
# which does NOT implement add_reader()/add_writer() — calls that paho-mqtt
|
||||
# requires internally. Setting the event loop *policy* alone is not enough
|
||||
# because uvicorn's "auto" factory bypasses it.
|
||||
#
|
||||
# The fix: re-exec the current process with "--loop none", which tells uvicorn
|
||||
# to let asyncio.run() create the loop through the standard policy (where we
|
||||
# have just installed WindowsSelectorEventLoopPolicy).
|
||||
#
|
||||
# Guards:
|
||||
# - "--loop" already in argv → we (or the operator) already handled it
|
||||
# - MESHCORE_NO_AUTO_LOOP_ON_WIN32=true → operator opt-out for custom
|
||||
# runners, test harnesses, or other non-uvicorn invocations
|
||||
# ---------------------------------------------------------------------------
|
||||
_win32_needs_reexec = (
|
||||
sys.platform == "win32"
|
||||
and os.environ.get("MESHCORE_NO_AUTO_LOOP_ON_WIN32", "").lower() not in ("true", "1")
|
||||
and "--loop" not in sys.argv
|
||||
)
|
||||
# Skip re-exec when --reload is active: on Windows os.execv spawns a new
|
||||
# process and exits, so the reloader's child dies and a fresh uvicorn
|
||||
# (with its own reloader) starts — creating doubled watchers or a loop.
|
||||
# Also skip if sys.executable is missing (embedded / frozen Python).
|
||||
if _win32_needs_reexec and "--reload" in sys.argv:
|
||||
print(
|
||||
"\n" + "!" * 78 + "\n"
|
||||
" WINDOWS + --reload DETECTED\n" + "!" * 78 + "\n"
|
||||
"\n"
|
||||
" We can't auto-fix the event loop when --reload is active because\n"
|
||||
" the re-exec would fight with uvicorn's reloader process.\n"
|
||||
"\n"
|
||||
" If you need MQTT fanout, add --loop none to your command:\n"
|
||||
"\n"
|
||||
" uv run uvicorn app.main:app --reload \033[1m--loop none\033[0m [... other options ...]\n"
|
||||
"\n"
|
||||
" Everything else works fine as-is.\n"
|
||||
"\n" + "!" * 78 + "\n",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
_win32_needs_reexec = False
|
||||
|
||||
if _win32_needs_reexec and not sys.executable:
|
||||
# Embedded or frozen Python — can't re-exec, just warn.
|
||||
_win32_needs_reexec = False
|
||||
|
||||
if _win32_needs_reexec:
|
||||
import asyncio as _asyncio
|
||||
|
||||
_asyncio.set_event_loop_policy(
|
||||
_asyncio.WindowsSelectorEventLoopPolicy() # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
print(
|
||||
"\n" + "=" * 78 + "\n"
|
||||
" HALLO FRIEND WINDOWS USER <3 WE GOTTA ADJUST THINGS BEFORE YOU STARTUP\n"
|
||||
+ "="
|
||||
* 78
|
||||
+ "\n"
|
||||
"\n"
|
||||
" uvicorn's default event loop on Windows (ProactorEventLoop) is not\n"
|
||||
" compatible with aiomqtt/paho-mqtt, which require add_reader() /\n"
|
||||
" add_writer(). Re-executing with '--loop none' so uvicorn honours\n"
|
||||
" WindowsSelectorEventLoopPolicy and MQTT fanout can function.\n"
|
||||
""
|
||||
" In English: The code we use for MQTT is fussy. We're restarting\n"
|
||||
" the server with the right settings for MQTT to work.\n"
|
||||
"\n"
|
||||
" This may or may not work :) If the app starts up after this without a warning, you're good to go.\n"
|
||||
"\n" + "=" * 78 + "\n",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# sys.argv[0] on Windows is typically a .exe console-script launcher
|
||||
# (e.g. .venv\Scripts\uvicorn.exe) which Python can't open as a script.
|
||||
# use "python -m uvicorn" instead, forwarding the original arguments.
|
||||
# yes, this is brittle as all hell.
|
||||
try:
|
||||
os.execv(
|
||||
sys.executable,
|
||||
[sys.executable, "-m", "uvicorn"] + sys.argv[1:] + ["--loop", "none"],
|
||||
)
|
||||
except Exception:
|
||||
# execv failed — fall through and let the app start normally.
|
||||
# MQTT fanout will not work, but everything else will.
|
||||
print(
|
||||
"\n" + "!" * 78 + "\n"
|
||||
" AUTO-RESTART FAILED :<\n" + "!" * 78 + "\n"
|
||||
"\n"
|
||||
" We tried to restart uvicorn with the necessary settings\n"
|
||||
" automatically, but there was a problem with the invocation\n"
|
||||
" (not shocking; this is a fragile system).\n"
|
||||
"\n"
|
||||
" Please rerun RemoteTerm with a command like:\n"
|
||||
"\n"
|
||||
" uv run uvicorn app.main:app \033[1m--loop none\033[0m [... other options ...]\n"
|
||||
"\n"
|
||||
" Setting '--loop none' on uvicorn startup will put you in a good\n"
|
||||
" state for MQTT and bypass this self-repair.\n"
|
||||
"\n"
|
||||
" The server is starting anyway -- everything except MQTT fanout\n"
|
||||
" will work normally. If you want to suppress this attempt, \n"
|
||||
" set the env var MESHCORE_NO_AUTO_LOOP_ON_WIN32=true\n"
|
||||
"\n" + "!" * 78 + "\n",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.radio import RadioDisconnectedError, radio_manager
|
||||
from app.frontend_static import (
|
||||
register_first_available_frontend_static_routes,
|
||||
register_frontend_missing_fallback,
|
||||
)
|
||||
from app.radio import RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_background_contact_reconciliation,
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
stop_periodic_sync,
|
||||
stop_telemetry_collect,
|
||||
)
|
||||
from app.routers import (
|
||||
channels,
|
||||
contacts,
|
||||
debug,
|
||||
fanout,
|
||||
health,
|
||||
messages,
|
||||
packets,
|
||||
radio,
|
||||
read_state,
|
||||
repeaters,
|
||||
rooms,
|
||||
settings,
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _startup_radio_connect_and_setup() -> None:
|
||||
"""Connect/setup the radio in the background so HTTP serving can start immediately."""
|
||||
try:
|
||||
connected = await radio_manager.reconnect_and_prepare(broadcast_on_success=True)
|
||||
if connected:
|
||||
logger.info("Connected to radio")
|
||||
else:
|
||||
logger.warning("Failed to connect to radio on startup")
|
||||
except Exception:
|
||||
logger.exception("Failed to connect to radio on startup")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage database and radio connection lifecycle."""
|
||||
@@ -45,63 +189,54 @@ async def lifespan(app: FastAPI):
|
||||
from app.radio_sync import ensure_default_channels
|
||||
|
||||
await ensure_default_channels()
|
||||
|
||||
try:
|
||||
await radio_manager.connect()
|
||||
logger.info("Connected to radio")
|
||||
await radio_manager.post_connect_setup()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to radio on startup: %s", e)
|
||||
await start_noise_floor_sampling()
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
|
||||
# Start MQTT publishers if configured
|
||||
from app.community_mqtt import community_publisher
|
||||
from app.mqtt import mqtt_publisher
|
||||
from app.repository import AppSettingsRepository
|
||||
# Start fanout modules (MQTT, etc.) from database configs
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
try:
|
||||
mqtt_settings = await AppSettingsRepository.get()
|
||||
await mqtt_publisher.start(mqtt_settings)
|
||||
await community_publisher.start(mqtt_settings)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start MQTT publisher(s): %s", e)
|
||||
await fanout_manager.load_from_db()
|
||||
except Exception:
|
||||
logger.exception("Failed to start fanout modules")
|
||||
|
||||
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
||||
app.state.startup_radio_task = startup_radio_task
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down")
|
||||
await community_publisher.stop()
|
||||
await mqtt_publisher.stop()
|
||||
if startup_radio_task and not startup_radio_task.done():
|
||||
startup_radio_task.cancel()
|
||||
try:
|
||||
await startup_radio_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await fanout_manager.stop_all()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_background_contact_reconciliation()
|
||||
await stop_message_polling()
|
||||
await stop_noise_floor_sampling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
await stop_telemetry_collect()
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||
await radio_manager.disconnect()
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Read version from pyproject.toml so it stays in sync automatically."""
|
||||
try:
|
||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
for line in pyproject.read_text().splitlines():
|
||||
if line.startswith("version = "):
|
||||
return line.split('"')[1]
|
||||
except Exception:
|
||||
pass
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="RemoteTerm for MeshCore API",
|
||||
description="API for interacting with MeshCore mesh radio networks",
|
||||
version=_get_version(),
|
||||
version=get_app_build_info().version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
add_optional_basic_auth_middleware(app, server_settings)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -119,9 +254,12 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
||||
|
||||
# API routes - all prefixed with /api for production compatibility
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(debug.router, prefix="/api")
|
||||
app.include_router(fanout.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
app.include_router(repeaters.router, prefix="/api")
|
||||
app.include_router(rooms.router, prefix="/api")
|
||||
app.include_router(channels.router, prefix="/api")
|
||||
app.include_router(messages.router, prefix="/api")
|
||||
app.include_router(packets.router, prefix="/api")
|
||||
@@ -131,6 +269,9 @@ app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
if not register_frontend_static_routes(app, FRONTEND_DIR):
|
||||
FRONTEND_DIST_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
FRONTEND_PREBUILT_DIR = Path(__file__).parent.parent / "frontend" / "prebuilt"
|
||||
if not register_first_available_frontend_static_routes(
|
||||
app, [FRONTEND_DIST_DIR, FRONTEND_PREBUILT_DIR]
|
||||
):
|
||||
register_frontend_missing_fallback(app)
|
||||
|
||||
+1449
-68
File diff suppressed because it is too large
Load Diff
+514
-113
@@ -2,60 +2,218 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route, normalize_route_override
|
||||
|
||||
# Valid MeshCore contact types: 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor.
|
||||
# Corrupted radio data can produce values outside this range.
|
||||
_VALID_CONTACT_TYPES = frozenset({0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
class ContactRoute(BaseModel):
|
||||
"""A normalized contact route."""
|
||||
|
||||
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
|
||||
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
|
||||
path_hash_mode: int = Field(
|
||||
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
|
||||
)
|
||||
|
||||
|
||||
class ContactUpsert(BaseModel):
|
||||
"""Typed write contract for contacts persisted to SQLite."""
|
||||
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str | None = None
|
||||
type: int = 0
|
||||
flags: int = 0
|
||||
direct_path: str | None = None
|
||||
direct_path_len: int | None = None
|
||||
direct_path_hash_mode: int | None = None
|
||||
direct_path_updated_at: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
last_seen: int | None = None
|
||||
on_radio: bool | None = None
|
||||
last_contacted: int | None = None
|
||||
first_seen: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
|
||||
return cls.model_validate(
|
||||
{
|
||||
**contact.model_dump(exclude={"last_read_at"}),
|
||||
**changes,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_radio_dict(
|
||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||
) -> "ContactUpsert":
|
||||
"""Convert radio contact data to the contact-row write shape."""
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
radio_data.get("out_path_len", -1),
|
||||
radio_data.get(
|
||||
"out_path_hash_mode",
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
)
|
||||
# Clamp invalid contact types to 0 (unknown) — corrupted radio data
|
||||
# can produce values like 111 or 240 that break downstream branching.
|
||||
raw_type = radio_data.get("type", 0)
|
||||
contact_type = raw_type if raw_type in _VALID_CONTACT_TYPES else 0
|
||||
|
||||
# Null out impossible coordinates — the contact is still ingested,
|
||||
# but garbage lat/lon (e.g. 1953.7) is discarded rather than stored.
|
||||
lat = radio_data.get("adv_lat")
|
||||
lon = radio_data.get("adv_lon")
|
||||
if lat is not None and not (-90 <= lat <= 90):
|
||||
lat = None
|
||||
if lon is not None and not (-180 <= lon <= 180):
|
||||
lon = None
|
||||
|
||||
return cls(
|
||||
public_key=public_key,
|
||||
name=radio_data.get("adv_name"),
|
||||
type=contact_type,
|
||||
flags=radio_data.get("flags", 0),
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
on_radio=on_radio,
|
||||
)
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str | None = None
|
||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room
|
||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
direct_path: str | None = None
|
||||
direct_path_len: int = -1
|
||||
direct_path_hash_mode: int = -1
|
||||
direct_path_updated_at: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
last_seen: int | None = None
|
||||
on_radio: bool = False
|
||||
favorite: bool = False
|
||||
last_contacted: int | None = None # Last time we sent/received a message
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
effective_route: ContactRoute | None = None
|
||||
effective_route_source: Literal["override", "direct", "flood"] = "flood"
|
||||
direct_route: ContactRoute | None = None
|
||||
route_override: ContactRoute | None = None
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
self.direct_path,
|
||||
self.direct_path_len,
|
||||
self.direct_path_hash_mode,
|
||||
)
|
||||
self.direct_path = direct_path or None
|
||||
self.direct_path_len = direct_path_len
|
||||
self.direct_path_hash_mode = direct_path_hash_mode
|
||||
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
self.route_override_path = route_override_path or None
|
||||
self.route_override_len = route_override_len
|
||||
self.route_override_hash_mode = route_override_hash_mode
|
||||
if (
|
||||
route_override_path is not None
|
||||
and route_override_len is not None
|
||||
and route_override_hash_mode is not None
|
||||
):
|
||||
self.route_override = ContactRoute(
|
||||
path=route_override_path,
|
||||
path_len=route_override_len,
|
||||
path_hash_mode=route_override_hash_mode,
|
||||
)
|
||||
else:
|
||||
self.route_override = None
|
||||
|
||||
if direct_path_len >= 0:
|
||||
self.direct_route = ContactRoute(
|
||||
path=direct_path,
|
||||
path_len=direct_path_len,
|
||||
path_hash_mode=direct_path_hash_mode,
|
||||
)
|
||||
else:
|
||||
self.direct_route = None
|
||||
|
||||
path, path_len, path_hash_mode = self.effective_route_tuple()
|
||||
if self.has_route_override():
|
||||
self.effective_route_source = "override"
|
||||
elif self.direct_route is not None:
|
||||
self.effective_route_source = "direct"
|
||||
else:
|
||||
self.effective_route_source = "flood"
|
||||
self.effective_route = ContactRoute(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
path_hash_mode=path_hash_mode,
|
||||
)
|
||||
|
||||
def has_route_override(self) -> bool:
|
||||
return self.route_override_len is not None
|
||||
|
||||
def effective_route_tuple(self) -> tuple[str, int, int]:
|
||||
if self.has_route_override():
|
||||
return normalize_contact_route(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
if self.direct_path_len >= 0:
|
||||
return normalize_contact_route(
|
||||
self.direct_path,
|
||||
self.direct_path_len,
|
||||
self.direct_path_hash_mode,
|
||||
)
|
||||
return "", -1, -1
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
than our database schema (name, direct_path, etc.).
|
||||
"""
|
||||
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"adv_name": self.name or "",
|
||||
"type": self.type,
|
||||
"flags": self.flags,
|
||||
"out_path": self.last_path or "",
|
||||
"out_path_len": self.last_path_len,
|
||||
"out_path": effective_path,
|
||||
"out_path_len": effective_path_len,
|
||||
"out_path_hash_mode": effective_path_hash_mode,
|
||||
"adv_lat": self.lat if self.lat is not None else 0.0,
|
||||
"adv_lon": self.lon if self.lon is not None else 0.0,
|
||||
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
|
||||
"""Convert radio contact data to database format dict.
|
||||
|
||||
This is the inverse of to_radio_dict(), used when syncing contacts
|
||||
from radio to database.
|
||||
"""
|
||||
return {
|
||||
"public_key": public_key,
|
||||
"name": radio_data.get("adv_name"),
|
||||
"type": radio_data.get("type", 0),
|
||||
"flags": radio_data.get("flags", 0),
|
||||
"last_path": radio_data.get("out_path"),
|
||||
"last_path_len": radio_data.get("out_path_len", -1),
|
||||
"lat": radio_data.get("adv_lat"),
|
||||
"lon": radio_data.get("adv_lon"),
|
||||
"last_advert": radio_data.get("last_advert"),
|
||||
"on_radio": on_radio,
|
||||
}
|
||||
def to_upsert(self, **changes) -> ContactUpsert:
|
||||
"""Convert the stored contact to the repository's write contract."""
|
||||
return ContactUpsert.from_contact(self, **changes)
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
@@ -69,8 +227,21 @@ class CreateContactRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ContactRoutingOverrideRequest(BaseModel):
|
||||
"""Request to set, force, or clear a contact routing override."""
|
||||
|
||||
route: str = Field(
|
||||
description=(
|
||||
"Blank clears the override, "
|
||||
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
||||
"comma-separated 1/2/3-byte hop hex values"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
CONTACT_TYPE_ROOM = 3
|
||||
|
||||
|
||||
class ContactAdvertPath(BaseModel):
|
||||
@@ -79,7 +250,8 @@ class ContactAdvertPath(BaseModel):
|
||||
path: str = Field(description="Hex-encoded routing path (empty string for direct)")
|
||||
path_len: int = Field(description="Number of hops in the path")
|
||||
next_hop: str | None = Field(
|
||||
default=None, description="First hop toward us (2-char hex), or null for direct"
|
||||
default=None,
|
||||
description="First hop toward us as a full hop identifier, or null for direct",
|
||||
)
|
||||
first_seen: int = Field(description="Unix timestamp of first observation")
|
||||
last_seen: int = Field(description="Unix timestamp of most recent observation")
|
||||
@@ -104,7 +276,7 @@ class ContactNameHistory(BaseModel):
|
||||
|
||||
|
||||
class ContactActiveRoom(BaseModel):
|
||||
"""A channel/room where a contact has been active."""
|
||||
"""A channel where a contact has been active."""
|
||||
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
@@ -121,13 +293,33 @@ class NearestRepeater(BaseModel):
|
||||
heard_count: int
|
||||
|
||||
|
||||
class ContactDetail(BaseModel):
|
||||
"""Comprehensive contact profile data."""
|
||||
class ContactAnalyticsHourlyBucket(BaseModel):
|
||||
"""A single hourly activity bucket for contact analytics."""
|
||||
|
||||
contact: Contact
|
||||
bucket_start: int = Field(description="Unix timestamp for the start of the hour bucket")
|
||||
last_24h_count: int = 0
|
||||
last_week_average: float = 0
|
||||
all_time_average: float = 0
|
||||
|
||||
|
||||
class ContactAnalyticsWeeklyBucket(BaseModel):
|
||||
"""A single weekly activity bucket for contact analytics."""
|
||||
|
||||
bucket_start: int = Field(description="Unix timestamp for the start of the 7-day bucket")
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
class ContactAnalytics(BaseModel):
|
||||
"""Unified contact analytics payload for keyed and name-only lookups."""
|
||||
|
||||
lookup_type: Literal["contact", "name"]
|
||||
name: str
|
||||
contact: Contact | None = None
|
||||
name_first_seen_at: int | None = None
|
||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
||||
dm_message_count: int = 0
|
||||
channel_message_count: int = 0
|
||||
includes_direct_messages: bool = False
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
|
||||
advert_frequency: float | None = Field(
|
||||
@@ -135,6 +327,8 @@ class ContactDetail(BaseModel):
|
||||
description="Advert observations per hour (includes multi-path arrivals of same advert)",
|
||||
)
|
||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
||||
hourly_activity: list[ContactAnalyticsHourlyBucket] = Field(default_factory=list)
|
||||
weekly_activity: list[ContactAnalyticsWeeklyBucket] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
@@ -142,14 +336,70 @@ class Channel(BaseModel):
|
||||
name: str
|
||||
is_hashtag: bool = False
|
||||
on_radio: bool = False
|
||||
flood_scope_override: str | None = Field(
|
||||
default=None,
|
||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
||||
)
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
favorite: bool = False
|
||||
|
||||
|
||||
class ChannelMessageCounts(BaseModel):
|
||||
"""Time-windowed message counts for a channel."""
|
||||
|
||||
last_1h: int = 0
|
||||
last_24h: int = 0
|
||||
last_48h: int = 0
|
||||
last_7d: int = 0
|
||||
all_time: int = 0
|
||||
|
||||
|
||||
class ChannelTopSender(BaseModel):
|
||||
"""A top sender in a channel over the last 24 hours."""
|
||||
|
||||
sender_name: str
|
||||
sender_key: str | None = None
|
||||
message_count: int
|
||||
|
||||
|
||||
class PathHashWidthStats(BaseModel):
|
||||
"""Hop byte width distribution for parsed raw packets."""
|
||||
|
||||
total_packets: int = 0
|
||||
single_byte: int = 0
|
||||
double_byte: int = 0
|
||||
triple_byte: int = 0
|
||||
single_byte_pct: float = 0.0
|
||||
double_byte_pct: float = 0.0
|
||||
triple_byte_pct: float = 0.0
|
||||
|
||||
|
||||
class ChannelDetail(BaseModel):
|
||||
"""Comprehensive channel profile data."""
|
||||
|
||||
channel: Channel
|
||||
message_counts: ChannelMessageCounts = Field(default_factory=ChannelMessageCounts)
|
||||
first_message_at: int | None = None
|
||||
unique_sender_count: int = 0
|
||||
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
||||
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
|
||||
|
||||
|
||||
class MessagePath(BaseModel):
|
||||
"""A single path that a message took to reach us."""
|
||||
|
||||
path: str = Field(description="Hex-encoded routing path (2 chars per hop)")
|
||||
path: str = Field(description="Hex-encoded routing path")
|
||||
received_at: int = Field(description="Unix timestamp when this path was received")
|
||||
path_len: int | None = Field(
|
||||
default=None,
|
||||
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
||||
)
|
||||
rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm")
|
||||
snr: float | None = Field(default=None, description="Last-hop SNR in dB")
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -164,8 +414,27 @@ class Message(BaseModel):
|
||||
)
|
||||
txt_type: int = 0
|
||||
signature: str | None = None
|
||||
sender_key: str | None = None
|
||||
outgoing: bool = False
|
||||
acked: int = 0
|
||||
sender_name: str | None = None
|
||||
channel_name: str | None = None
|
||||
packet_id: int | None = Field(
|
||||
default=None,
|
||||
description="Representative raw packet row ID when archival raw bytes exist",
|
||||
)
|
||||
|
||||
|
||||
class MessagesAroundResponse(BaseModel):
|
||||
messages: list[Message]
|
||||
has_older: bool
|
||||
has_newer: bool
|
||||
|
||||
|
||||
class ResendChannelMessageResponse(BaseModel):
|
||||
status: str
|
||||
message_id: int
|
||||
message: Message | None = None
|
||||
|
||||
|
||||
class RawPacketDecryptedInfo(BaseModel):
|
||||
@@ -199,6 +468,21 @@ class RawPacketBroadcast(BaseModel):
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class RawPacketDetail(BaseModel):
|
||||
"""Stored raw-packet detail returned by the packet API."""
|
||||
|
||||
id: int
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)")
|
||||
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available")
|
||||
rssi: int | None = Field(
|
||||
default=None, description="Received signal strength in dBm if available"
|
||||
)
|
||||
decrypted: bool = False
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
@@ -225,6 +509,11 @@ class RepeaterLoginResponse(BaseModel):
|
||||
"""Response from repeater login."""
|
||||
|
||||
status: str = Field(description="Login result status")
|
||||
authenticated: bool = Field(description="Whether repeater authentication was confirmed")
|
||||
message: str | None = Field(
|
||||
default=None,
|
||||
description="Optional warning or error message when authentication was not confirmed",
|
||||
)
|
||||
|
||||
|
||||
class RepeaterStatusResponse(BaseModel):
|
||||
@@ -247,10 +536,22 @@ class RepeaterStatusResponse(BaseModel):
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterNodeInfoResponse(BaseModel):
|
||||
"""Identity/location info from a repeater (small CLI batch)."""
|
||||
|
||||
name: str | None = Field(default=None, description="Repeater name")
|
||||
lat: str | None = Field(default=None, description="Latitude")
|
||||
lon: str | None = Field(default=None, description="Longitude")
|
||||
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
|
||||
|
||||
|
||||
class RepeaterRadioSettingsResponse(BaseModel):
|
||||
"""Radio settings from a repeater (batch CLI get commands)."""
|
||||
"""Radio settings from a repeater (radio/config CLI batch)."""
|
||||
|
||||
firmware_version: str | None = Field(default=None, description="Firmware version string")
|
||||
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
|
||||
@@ -258,10 +559,6 @@ class RepeaterRadioSettingsResponse(BaseModel):
|
||||
airtime_factor: str | None = Field(default=None, description="Airtime factor")
|
||||
repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled")
|
||||
flood_max: str | None = Field(default=None, description="Max flood hops")
|
||||
name: str | None = Field(default=None, description="Repeater name")
|
||||
lat: str | None = Field(default=None, description="Latitude")
|
||||
lon: str | None = Field(default=None, description="Longitude")
|
||||
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
|
||||
|
||||
|
||||
class RepeaterAdvertIntervalsResponse(BaseModel):
|
||||
@@ -340,6 +637,83 @@ class TraceResponse(BaseModel):
|
||||
path_len: int = Field(description="Number of hops in the trace path")
|
||||
|
||||
|
||||
class RadioTraceHopRequest(BaseModel):
|
||||
"""One requested hop in a radio trace path."""
|
||||
|
||||
public_key: str | None = Field(
|
||||
default=None,
|
||||
description="Full repeater public key when this hop maps to a known repeater",
|
||||
)
|
||||
hop_hex: str | None = Field(
|
||||
default=None,
|
||||
description="Raw hop hash hex when using a custom repeater prefix",
|
||||
)
|
||||
|
||||
|
||||
class RadioTraceRequest(BaseModel):
|
||||
"""Ordered trace path for a radio trace loop."""
|
||||
|
||||
hop_hash_bytes: Literal[1, 2, 4] = Field(
|
||||
default=4,
|
||||
description="Hash width in bytes for every hop in this trace path",
|
||||
)
|
||||
hops: list[RadioTraceHopRequest] = Field(
|
||||
min_length=1,
|
||||
description="Ordered repeater hops, using either known repeater keys or custom hop hex",
|
||||
)
|
||||
|
||||
|
||||
class RadioTraceNode(BaseModel):
|
||||
"""One resolved node in a radio trace result."""
|
||||
|
||||
role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace")
|
||||
public_key: str | None = Field(
|
||||
default=None,
|
||||
description="Resolved full public key for this node when known",
|
||||
)
|
||||
name: str | None = Field(default=None, description="Display name for this node when known")
|
||||
observed_hash: str | None = Field(
|
||||
default=None,
|
||||
description="Observed 4-byte trace hash for this node as hex",
|
||||
)
|
||||
snr: float | None = Field(default=None, description="Reported SNR for this node in dB")
|
||||
|
||||
|
||||
class RadioTraceResponse(BaseModel):
|
||||
"""Resolved multi-hop radio trace result."""
|
||||
|
||||
path_len: int = Field(description="Number of hashed nodes returned by the trace response")
|
||||
timeout_seconds: float = Field(description="Timeout window used while waiting for the trace")
|
||||
nodes: list[RadioTraceNode] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered trace nodes: repeater hops followed by the terminal local radio",
|
||||
)
|
||||
|
||||
|
||||
class PathDiscoveryRoute(BaseModel):
|
||||
"""One resolved route returned by contact path discovery."""
|
||||
|
||||
path: str = Field(description="Hex-encoded path bytes")
|
||||
path_len: int = Field(description="Hop count for this route")
|
||||
path_hash_mode: int = Field(
|
||||
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
|
||||
)
|
||||
|
||||
|
||||
class PathDiscoveryResponse(BaseModel):
|
||||
"""Round-trip routing data for a contact path discovery request."""
|
||||
|
||||
contact: Contact = Field(
|
||||
description="Updated contact row after saving the learned forward path"
|
||||
)
|
||||
forward_path: PathDiscoveryRoute = Field(
|
||||
description="Route used from the local radio to the target contact"
|
||||
)
|
||||
return_path: PathDiscoveryRoute = Field(
|
||||
description="Route used from the target contact back to the local radio"
|
||||
)
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
"""Request to send a CLI command to a repeater."""
|
||||
|
||||
@@ -356,20 +730,50 @@ class CommandResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class Favorite(BaseModel):
|
||||
"""A favorite conversation."""
|
||||
class RadioDiscoveryRequest(BaseModel):
|
||||
"""Request to discover nearby mesh nodes from the local radio."""
|
||||
|
||||
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
target: Literal["repeaters", "sensors", "all"] = Field(
|
||||
default="all",
|
||||
description="Which node classes to discover over the mesh",
|
||||
)
|
||||
|
||||
|
||||
class BotConfig(BaseModel):
|
||||
"""Configuration for a single bot."""
|
||||
class RadioDiscoveryResult(BaseModel):
|
||||
"""One mesh node heard during a discovery sweep."""
|
||||
|
||||
id: str = Field(description="UUID for stable identity across renames/reorders")
|
||||
name: str = Field(description="User-editable name")
|
||||
enabled: bool = Field(default=False, description="Whether this bot is enabled")
|
||||
code: str = Field(default="", description="Python code for this bot")
|
||||
public_key: str = Field(description="Discovered node public key as hex")
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="Known name for this node from contacts DB, if any",
|
||||
)
|
||||
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
||||
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
||||
local_snr: float | None = Field(
|
||||
default=None,
|
||||
description="SNR at which the local radio heard the response (dB)",
|
||||
)
|
||||
local_rssi: int | None = Field(
|
||||
default=None,
|
||||
description="RSSI at which the local radio heard the response (dBm)",
|
||||
)
|
||||
remote_snr: float | None = Field(
|
||||
default=None,
|
||||
description="SNR reported by the remote node while hearing our discovery request (dB)",
|
||||
)
|
||||
|
||||
|
||||
class RadioDiscoveryResponse(BaseModel):
|
||||
"""Response payload for a mesh discovery sweep."""
|
||||
|
||||
target: Literal["repeaters", "sensors", "all"] = Field(
|
||||
description="Which node classes were requested"
|
||||
)
|
||||
duration_seconds: float = Field(description="How long the sweep listened for responses")
|
||||
results: list[RadioDiscoveryResult] = Field(
|
||||
default_factory=list,
|
||||
description="Deduplicated discovery responses heard during the sweep",
|
||||
)
|
||||
|
||||
|
||||
class UnreadCounts(BaseModel):
|
||||
@@ -384,6 +788,9 @@ class UnreadCounts(BaseModel):
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict, description="Map of stateKey -> last message timestamp"
|
||||
)
|
||||
last_read_ats: dict[str, int | None] = Field(
|
||||
default_factory=dict, description="Map of stateKey -> server-side last_read_at boundary"
|
||||
)
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
@@ -392,29 +799,18 @@ class AppSettings(BaseModel):
|
||||
max_radio_contacts: int = Field(
|
||||
default=200,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio for DM ACKs "
|
||||
"(favorite contacts first, then recent non-repeaters)"
|
||||
"Configured radio contact capacity used for maintenance thresholds; "
|
||||
"favorites reload first, then background fill targets about 80% of this value"
|
||||
),
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
default_factory=list, description="List of favorited conversations"
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool = Field(
|
||||
default=False,
|
||||
default=True,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
)
|
||||
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
||||
default="recent",
|
||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
||||
)
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of conversation state keys to last message timestamps",
|
||||
)
|
||||
preferences_migrated: bool = Field(
|
||||
default=False,
|
||||
description="Whether preferences have been migrated from localStorage",
|
||||
)
|
||||
advert_interval: int = Field(
|
||||
default=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
@@ -423,65 +819,35 @@ class AppSettings(BaseModel):
|
||||
default=0,
|
||||
description="Unix timestamp of last advertisement sent (0 = never)",
|
||||
)
|
||||
bots: list[BotConfig] = Field(
|
||||
flood_scope: str = Field(
|
||||
default="",
|
||||
description="Outbound flood scope / region name (empty = disabled, no tagging)",
|
||||
)
|
||||
blocked_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of bot configurations",
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
)
|
||||
mqtt_broker_host: str = Field(
|
||||
default="",
|
||||
description="MQTT broker hostname (empty = disabled)",
|
||||
blocked_names: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
mqtt_broker_port: int = Field(
|
||||
default=1883,
|
||||
description="MQTT broker port",
|
||||
discovery_blocked_types: list[int] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||
"advertisements should not create new contacts; existing contacts are still updated"
|
||||
),
|
||||
)
|
||||
mqtt_username: str = Field(
|
||||
default="",
|
||||
description="MQTT username (optional)",
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||
)
|
||||
mqtt_password: str = Field(
|
||||
default="",
|
||||
description="MQTT password (optional)",
|
||||
)
|
||||
mqtt_use_tls: bool = Field(
|
||||
auto_resend_channel: bool = Field(
|
||||
default=False,
|
||||
description="Whether to use TLS for MQTT connection",
|
||||
)
|
||||
mqtt_tls_insecure: bool = Field(
|
||||
default=False,
|
||||
description="Skip TLS certificate verification (for self-signed certs)",
|
||||
)
|
||||
mqtt_topic_prefix: str = Field(
|
||||
default="meshcore",
|
||||
description="MQTT topic prefix",
|
||||
)
|
||||
mqtt_publish_messages: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish decrypted messages to MQTT",
|
||||
)
|
||||
mqtt_publish_raw_packets: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
community_mqtt_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish raw packets to the community MQTT broker (letsmesh.net)",
|
||||
)
|
||||
community_mqtt_iata: str = Field(
|
||||
default="",
|
||||
description="IATA region code for community MQTT topic routing (3 alpha chars)",
|
||||
)
|
||||
community_mqtt_broker_host: str = Field(
|
||||
default="mqtt-us-v1.letsmesh.net",
|
||||
description="Community MQTT broker hostname",
|
||||
)
|
||||
community_mqtt_broker_port: int = Field(
|
||||
default=443,
|
||||
description="Community MQTT broker port",
|
||||
)
|
||||
community_mqtt_email: str = Field(
|
||||
default="",
|
||||
description="Email address for node claiming on the community aggregator (optional)",
|
||||
description=(
|
||||
"When enabled, outgoing channel messages that receive no echo within 2 seconds "
|
||||
"are automatically byte-perfect resent once (within the 30-second dedup window)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -497,6 +863,32 @@ class ContactActivityCounts(BaseModel):
|
||||
last_week: int
|
||||
|
||||
|
||||
class NoiseFloorSample(BaseModel):
|
||||
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
|
||||
|
||||
class NoiseFloorHistoryStats(BaseModel):
|
||||
sample_interval_seconds: int = Field(description="Expected spacing between samples")
|
||||
coverage_seconds: int = Field(description="How much of the last 24 hours is represented")
|
||||
latest_noise_floor_dbm: int | None = Field(
|
||||
default=None, description="Most recent sampled noise floor in dBm"
|
||||
)
|
||||
latest_timestamp: int | None = Field(
|
||||
default=None, description="Unix timestamp of the most recent sample"
|
||||
)
|
||||
supported: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether the connected radio appears to support radio stats sampling",
|
||||
)
|
||||
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PacketsPerHourBucket(BaseModel):
|
||||
timestamp: int = Field(description="Unix timestamp at the start of the hour")
|
||||
count: int = Field(description="Number of packets received in that hour")
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
@@ -510,3 +902,12 @@ class StatisticsResponse(BaseModel):
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
packets_per_hour_72h: list[PacketsPerHourBucket]
|
||||
noise_floor_24h: NoiseFloorHistoryStats
|
||||
|
||||
|
||||
class TelemetryHistoryEntry(BaseModel):
|
||||
timestamp: int
|
||||
data: dict
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.mqtt_base import BaseMqttPublisher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MqttPublisher(BaseMqttPublisher):
|
||||
"""Manages an MQTT connection and publishes mesh network events."""
|
||||
|
||||
_backoff_max = 30
|
||||
_log_prefix = "MQTT"
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if MQTT is configured and has something to publish."""
|
||||
return bool(
|
||||
self._settings
|
||||
and self._settings.mqtt_broker_host
|
||||
and (self._settings.mqtt_publish_messages or self._settings.mqtt_publish_raw_packets)
|
||||
)
|
||||
|
||||
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
|
||||
return {
|
||||
"hostname": settings.mqtt_broker_host,
|
||||
"port": settings.mqtt_broker_port,
|
||||
"username": settings.mqtt_username or None,
|
||||
"password": settings.mqtt_password or None,
|
||||
"tls_context": self._build_tls_context(settings),
|
||||
}
|
||||
|
||||
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
|
||||
return ("MQTT connected", f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}")
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return ("MQTT connection failure", "Please correct the settings or disable.")
|
||||
|
||||
@staticmethod
|
||||
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
|
||||
"""Build TLS context from settings, or None if TLS is disabled."""
|
||||
if not settings.mqtt_use_tls:
|
||||
return None
|
||||
ctx = ssl.create_default_context()
|
||||
if settings.mqtt_tls_insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
mqtt_publisher = MqttPublisher()
|
||||
|
||||
|
||||
def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
|
||||
if event_type not in ("message", "raw_packet"):
|
||||
return
|
||||
if not mqtt_publisher.connected or mqtt_publisher._settings is None:
|
||||
return
|
||||
asyncio.create_task(_mqtt_maybe_publish(event_type, data))
|
||||
|
||||
|
||||
async def _mqtt_maybe_publish(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Check settings and build topic, then publish."""
|
||||
settings = mqtt_publisher._settings
|
||||
if settings is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if event_type == "message" and settings.mqtt_publish_messages:
|
||||
topic = _build_message_topic(settings.mqtt_topic_prefix, data)
|
||||
await mqtt_publisher.publish(topic, data)
|
||||
|
||||
elif event_type == "raw_packet" and settings.mqtt_publish_raw_packets:
|
||||
topic = _build_raw_packet_topic(settings.mqtt_topic_prefix, data)
|
||||
await mqtt_publisher.publish(topic, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MQTT broadcast error: %s", e)
|
||||
|
||||
|
||||
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a decrypted message."""
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "unknown")
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"{prefix}/dm:{conversation_key}"
|
||||
elif msg_type == "CHAN":
|
||||
return f"{prefix}/gm:{conversation_key}"
|
||||
return f"{prefix}/message:{conversation_key}"
|
||||
|
||||
|
||||
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a raw packet."""
|
||||
info = data.get("decrypted_info")
|
||||
if info and isinstance(info, dict):
|
||||
contact_key = info.get("contact_key")
|
||||
channel_key = info.get("channel_key")
|
||||
if contact_key:
|
||||
return f"{prefix}/raw/dm:{contact_key}"
|
||||
if channel_key:
|
||||
return f"{prefix}/raw/gm:{channel_key}"
|
||||
return f"{prefix}/raw/unrouted"
|
||||
+240
-372
@@ -26,23 +26,32 @@ from app.decoder import (
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
try_decrypt_path,
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
Message,
|
||||
MessagePath,
|
||||
Contact,
|
||||
ContactUpsert,
|
||||
RawPacketBroadcast,
|
||||
RawPacketDecryptedInfo,
|
||||
)
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.messages import (
|
||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||
)
|
||||
from app.services.messages import (
|
||||
create_message_from_decrypted as _create_message_from_decrypted,
|
||||
)
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,76 +59,6 @@ logger = logging.getLogger(__name__)
|
||||
_raw_observation_counter = count(1)
|
||||
|
||||
|
||||
async def _handle_duplicate_message(
|
||||
packet_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
received: int,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record.
|
||||
|
||||
Called when MessageRepository.create returns None (INSERT OR IGNORE hit a duplicate).
|
||||
Looks up the existing message, adds the new path, increments ack count for outgoing
|
||||
messages, and broadcasts the update to clients.
|
||||
"""
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
if not existing_msg:
|
||||
label = "message" if msg_type == "CHAN" else "DM"
|
||||
logger.warning(
|
||||
"Duplicate %s for %s but couldn't find existing",
|
||||
label,
|
||||
conversation_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
msg_type,
|
||||
conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
# Add path if provided
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received)
|
||||
else:
|
||||
# Get current paths for broadcast
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
# Increment ack count for outgoing messages (echo confirmation)
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
# Only broadcast when something actually changed:
|
||||
# - outgoing: ack count was incremented
|
||||
# - path provided: a new path entry was appended
|
||||
# The path=None case happens for direct-delivery DMs (0-hop, no routing bytes).
|
||||
# A non-outgoing duplicate with no new path changes nothing in the DB, so skip.
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_event(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": existing_msg.id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [p.model_dump() for p in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
|
||||
# Mark this packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
packet_id: int,
|
||||
channel_key: str,
|
||||
@@ -128,107 +67,29 @@ async def create_message_from_decrypted(
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
trigger_bot: bool = True,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted channel packet content.
|
||||
|
||||
This is the shared logic for storing decrypted channel messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
channel_key: Hex string channel key
|
||||
channel_name: Channel name (e.g. "#general"), for bot context
|
||||
sender: Sender name (will be prefixed to message) or None
|
||||
message_text: The decrypted message content
|
||||
timestamp: Sender timestamp from the packet
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path
|
||||
trigger_bot: Whether to trigger bot response (False for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
received = received_at or int(time.time())
|
||||
|
||||
# Format the message text with sender prefix if present
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
|
||||
# Normalize channel key to uppercase for consistency
|
||||
channel_key_normalized = channel_key.upper()
|
||||
|
||||
# Resolve sender_key: look up contact by exact name match
|
||||
resolved_sender_key: str | None = None
|
||||
if sender:
|
||||
candidates = await ContactRepository.get_by_name(sender)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=channel_key_normalized,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
"""Store a decrypted channel message via the shared message service."""
|
||||
return await _create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key,
|
||||
sender=sender,
|
||||
message_text=message_text,
|
||||
timestamp=timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
channel_name=channel_name,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected - this happens when:
|
||||
# 1. Our own outgoing message echoes back (flood routing)
|
||||
# 2. Same message arrives via multiple paths before first is committed
|
||||
# In either case, add the path to the existing message.
|
||||
await _handle_duplicate_message(
|
||||
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
# Use "is not None" to include empty string (direct/0-hop messages)
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
# Run bot if enabled (for incoming channel messages, not historical decryption)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=sender,
|
||||
sender_key=None, # Channel messages don't have a sender public key
|
||||
message_text=message_text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_normalized,
|
||||
channel_name=channel_name,
|
||||
sender_timestamp=timestamp,
|
||||
path=path,
|
||||
is_outgoing=False,
|
||||
)
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
packet_id: int,
|
||||
@@ -237,122 +98,28 @@ async def create_dm_message_from_decrypted(
|
||||
our_public_key: str | None,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
trigger_bot: bool = True,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted direct message packet content.
|
||||
|
||||
This is the shared logic for storing decrypted direct messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
decrypted: DecryptedDirectMessage from decoder
|
||||
their_public_key: The contact's full 64-char public key (conversation_key)
|
||||
our_public_key: Our public key (to determine direction), or None
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path
|
||||
outgoing: Whether this is an outgoing message (we sent it)
|
||||
trigger_bot: Whether to trigger bot response (False for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
# Check if sender is a repeater - repeaters only send CLI responses, not chat messages.
|
||||
# CLI responses are handled by the command endpoint, not stored in chat history.
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
if contact and contact.type == CONTACT_TYPE_REPEATER:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (CLI responses not stored): %s",
|
||||
their_public_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
received = received_at or int(time.time())
|
||||
|
||||
# conversation_key is always the other party's public key
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
"""Store a decrypted direct message via the shared message service."""
|
||||
return await _create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=decrypted,
|
||||
their_public_key=their_public_key,
|
||||
our_public_key=our_public_key,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected
|
||||
await _handle_duplicate_message(
|
||||
packet_id,
|
||||
"PRIV",
|
||||
conversation_key,
|
||||
decrypted.message,
|
||||
decrypted.timestamp,
|
||||
path,
|
||||
received,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
outgoing,
|
||||
)
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
outgoing=outgoing,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
|
||||
# Run bot if enabled (for all real-time DMs, including our own outgoing messages)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
# Get contact name for the bot
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
sender_name = contact.name if contact else None
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=sender_name,
|
||||
sender_key=their_public_key,
|
||||
message_text=decrypted.message,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
is_outgoing=outgoing,
|
||||
)
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def run_historical_dm_decryption(
|
||||
private_key_bytes: bytes,
|
||||
@@ -363,20 +130,20 @@ async def run_historical_dm_decryption(
|
||||
"""Background task to decrypt historical DM packets with contact's key."""
|
||||
from app.websocket import broadcast_success
|
||||
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
total = len(packets)
|
||||
total = 0
|
||||
decrypted_count = 0
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||
return
|
||||
|
||||
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
|
||||
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
|
||||
|
||||
# Derive our public key from the private key
|
||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_undecrypted_text_messages():
|
||||
total += 1
|
||||
# Note: passing our_public_key=None disables the outbound hash check in
|
||||
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
||||
# For the 255/256 case where our first byte differs from the contact's,
|
||||
@@ -411,6 +178,7 @@ async def run_historical_dm_decryption(
|
||||
# Extract path from the raw packet for storage
|
||||
packet_info = parse_packet(packet_data)
|
||||
path_hex = packet_info.path.hex() if packet_info else None
|
||||
path_len = packet_info.path_length if packet_info else None
|
||||
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
@@ -419,13 +187,18 @@ async def run_historical_dm_decryption(
|
||||
our_public_key=our_public_key_bytes.hex(),
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
trigger_bot=False, # Historical decryption should not trigger bot
|
||||
realtime=False, # Historical decryption should not trigger fanout
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Historical DM decryption complete: %d/%d packets decrypted",
|
||||
decrypted_count,
|
||||
@@ -503,9 +276,10 @@ async def process_raw_packet(
|
||||
This is the main entry point for all incoming RF packets.
|
||||
|
||||
Note: Packets are deduplicated by payload hash in the database. If we receive
|
||||
a duplicate packet (same payload, different path), we still broadcast it to
|
||||
the frontend (for the real-time packet feed) but skip decryption processing
|
||||
since the original packet was already processed.
|
||||
a duplicate payload (same payload, different path), we still broadcast it to
|
||||
the frontend for realtime packet-feed fidelity. Some payload types are also
|
||||
intentionally reprocessed on duplicate arrival so message-level dedup/path
|
||||
merge logic and advert/path-history tracking still see each observation.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
observation_id = next(_raw_observation_counter)
|
||||
@@ -518,6 +292,13 @@ async def process_raw_packet(
|
||||
payload_type = packet_info.payload_type if packet_info else None
|
||||
payload_type_name = payload_type.name if payload_type else "Unknown"
|
||||
|
||||
if packet_info is None and len(raw_bytes) > 2:
|
||||
logger.warning(
|
||||
"Failed to parse %d-byte packet (id=%d); stored undecrypted",
|
||||
len(raw_bytes),
|
||||
packet_id,
|
||||
)
|
||||
|
||||
# Log packet arrival at debug level
|
||||
path_hex = packet_info.path.hex() if packet_info and packet_info.path else ""
|
||||
logger.debug(
|
||||
@@ -546,22 +327,28 @@ async def process_raw_packet(
|
||||
# deduplication in create_message_from_decrypted handles adding paths to existing messages.
|
||||
# This is more reliable than trying to look up the message via raw packet linking.
|
||||
if payload_type == PayloadType.GROUP_TEXT:
|
||||
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_group_text(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
elif payload_type == PayloadType.ADVERT:
|
||||
# Process all advert arrivals (even payload-hash duplicates) so the
|
||||
# path-freshness logic in _process_advertisement can pick the shortest
|
||||
# path heard within the freshness window.
|
||||
# advert-history table retains recent path observations.
|
||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# Try to decrypt direct messages using stored private key and known contacts
|
||||
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_direct_message(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
elif payload_type == PayloadType.PATH:
|
||||
await _process_path_packet(raw_bytes, ts, packet_info)
|
||||
|
||||
# Always broadcast raw packet for the packet feed UI (even duplicates)
|
||||
# This enables the frontend cracker to see all incoming packets in real-time
|
||||
broadcast_payload = RawPacketBroadcast(
|
||||
@@ -592,6 +379,8 @@ async def _process_group_text(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a GroupText (channel message) packet.
|
||||
@@ -627,6 +416,9 @@ async def _process_group_text(
|
||||
timestamp=decrypted.timestamp,
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -650,7 +442,6 @@ async def _process_advertisement(
|
||||
Process an advertisement packet.
|
||||
|
||||
Extracts contact info and updates the database/broadcasts to clients.
|
||||
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
|
||||
"""
|
||||
# Parse packet to get path info if not already provided
|
||||
if packet_info is None:
|
||||
@@ -659,107 +450,100 @@ async def _process_advertisement(
|
||||
logger.debug("Failed to parse advertisement packet")
|
||||
return
|
||||
|
||||
advert = parse_advertisement(packet_info.payload)
|
||||
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
|
||||
if not advert:
|
||||
logger.debug("Failed to parse advertisement payload")
|
||||
return
|
||||
|
||||
# Extract path info from packet
|
||||
new_path_len = packet_info.path_length
|
||||
new_path_hex = packet_info.path.hex() if packet_info.path else ""
|
||||
|
||||
# Try to find existing contact
|
||||
existing = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||
|
||||
# Determine which path to use: keep shorter path if heard recently (within 60s)
|
||||
# This handles advertisement echoes through different routes
|
||||
PATH_FRESHNESS_SECONDS = 60
|
||||
use_existing_path = False
|
||||
|
||||
if existing and existing.last_seen:
|
||||
path_age = timestamp - existing.last_seen
|
||||
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
|
||||
|
||||
# Keep existing path if it's fresh and shorter (or equal)
|
||||
if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len:
|
||||
use_existing_path = True
|
||||
logger.debug(
|
||||
"Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)",
|
||||
advert.public_key[:12],
|
||||
existing_path_len,
|
||||
new_path_len,
|
||||
path_age,
|
||||
)
|
||||
|
||||
if use_existing_path:
|
||||
assert existing is not None # Guaranteed by the conditions that set use_existing_path
|
||||
path_len = existing.last_path_len if existing.last_path_len is not None else -1
|
||||
path_hex = existing.last_path or ""
|
||||
else:
|
||||
path_len = new_path_len
|
||||
path_hex = new_path_hex
|
||||
|
||||
logger.debug(
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)",
|
||||
advert.public_key[:12],
|
||||
advert.name,
|
||||
advert.device_role,
|
||||
advert.lat,
|
||||
advert.lon,
|
||||
path_len,
|
||||
new_path_len,
|
||||
)
|
||||
|
||||
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor)
|
||||
# Use advert.timestamp for last_advert (sender's timestamp), receive timestamp for last_seen
|
||||
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
|
||||
# Persist advert freshness fields using the server receive wall clock so
|
||||
# route selection is not affected by sender clock skew.
|
||||
contact_type = (
|
||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||
)
|
||||
|
||||
# Check discovery_blocked_types: skip new contacts whose type is blocked.
|
||||
# Existing contacts are always updated (location, name, last_seen, etc.).
|
||||
if existing is None and contact_type > 0:
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
settings = await AppSettingsRepository.get()
|
||||
if contact_type in settings.discovery_blocked_types:
|
||||
logger.debug(
|
||||
"Skipping new contact %s: type %d is in discovery_blocked_types",
|
||||
advert.public_key[:12],
|
||||
contact_type,
|
||||
)
|
||||
return
|
||||
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=advert.public_key.lower(),
|
||||
name=advert.name,
|
||||
type=contact_type,
|
||||
lat=advert.lat,
|
||||
lon=advert.lon,
|
||||
last_advert=timestamp,
|
||||
last_seen=timestamp,
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
# Upsert the contact BEFORE recording advert paths so the parent row
|
||||
# exists when foreign key enforcement is enabled.
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
|
||||
# Keep recent unique advert paths for all contacts.
|
||||
await ContactAdvertPathRepository.record_observation(
|
||||
public_key=advert.public_key.lower(),
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=advert.public_key,
|
||||
log=logger,
|
||||
)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=advert.public_key,
|
||||
contact_name=advert.name,
|
||||
timestamp=timestamp,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Record name history
|
||||
if advert.name:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key=advert.public_key.lower(),
|
||||
name=advert.name,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
contact_data = {
|
||||
"public_key": advert.public_key.lower(),
|
||||
"name": advert.name,
|
||||
"type": contact_type,
|
||||
"lat": advert.lat,
|
||||
"lon": advert.lon,
|
||||
"last_advert": advert.timestamp if advert.timestamp > 0 else timestamp,
|
||||
"last_seen": timestamp,
|
||||
"last_path": path_hex,
|
||||
"last_path_len": path_len,
|
||||
"first_seen": timestamp, # COALESCE in upsert preserves existing value
|
||||
}
|
||||
|
||||
await ContactRepository.upsert(contact_data)
|
||||
claimed = await MessageRepository.claim_prefix_messages(advert.public_key.lower())
|
||||
if claimed > 0:
|
||||
logger.info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
advert.public_key[:12],
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly.
|
||||
db_contact = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||
if db_contact:
|
||||
broadcast_event("contact", db_contact.model_dump())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
broadcast_event("contact", contact_data)
|
||||
broadcast_event(
|
||||
"contact",
|
||||
Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump(),
|
||||
)
|
||||
|
||||
# For new contacts, optionally attempt to decrypt any historical DMs we may have stored
|
||||
# This is controlled by the auto_decrypt_dm_on_advert setting
|
||||
@@ -770,20 +554,14 @@ async def _process_advertisement(
|
||||
if settings.auto_decrypt_dm_on_advert:
|
||||
await start_historical_dm_decryption(None, advert.public_key.lower(), advert.name)
|
||||
|
||||
# If this is not a repeater, trigger recent contacts sync to radio
|
||||
# This ensures we can auto-ACK DMs from recent contacts
|
||||
if contact_type != CONTACT_TYPE_REPEATER:
|
||||
# Import here to avoid circular import
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio())
|
||||
|
||||
|
||||
async def _process_direct_message(
|
||||
raw_bytes: bytes,
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a TEXT_MESSAGE (direct message) packet.
|
||||
@@ -882,7 +660,10 @@ async def _process_direct_message(
|
||||
their_public_key=contact.public_key,
|
||||
our_public_key=our_public_key.hex(),
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info.path else None,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
@@ -897,3 +678,90 @@ async def _process_direct_message(
|
||||
# Couldn't decrypt with any known contact
|
||||
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
|
||||
return None
|
||||
|
||||
|
||||
async def _process_path_packet(
|
||||
raw_bytes: bytes,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
) -> None:
|
||||
"""Process a PATH packet and update the learned direct route."""
|
||||
if not has_private_key():
|
||||
return
|
||||
|
||||
private_key = get_private_key()
|
||||
our_public_key = get_public_key()
|
||||
if private_key is None or our_public_key is None:
|
||||
return
|
||||
|
||||
if packet_info is None:
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
|
||||
return
|
||||
|
||||
dest_hash = format(packet_info.payload[0], "02x").lower()
|
||||
src_hash = format(packet_info.payload[1], "02x").lower()
|
||||
our_first_byte = format(our_public_key[0], "02x").lower()
|
||||
if dest_hash != our_first_byte:
|
||||
return
|
||||
|
||||
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
|
||||
if not candidate_contacts:
|
||||
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
|
||||
return
|
||||
|
||||
for contact in candidate_contacts:
|
||||
if len(contact.public_key) != 64:
|
||||
continue
|
||||
try:
|
||||
contact_public_key = bytes.fromhex(contact.public_key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
result = try_decrypt_path(
|
||||
raw_packet=raw_bytes,
|
||||
our_private_key=private_key,
|
||||
their_public_key=contact_public_key,
|
||||
our_public_key=our_public_key,
|
||||
)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
result.returned_path.hex(),
|
||||
result.returned_path_len,
|
||||
result.returned_path_hash_mode,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
|
||||
ack_code = result.extra[:4].hex()
|
||||
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||
if matched:
|
||||
logger.info(
|
||||
"Applied bundled PATH ACK for %s via contact %s",
|
||||
ack_code,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Buffered bundled PATH ACK %s via contact %s",
|
||||
ack_code,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
|
||||
logger.debug(
|
||||
"Observed bundled PATH RESPONSE from %s (%d bytes)",
|
||||
contact.public_key[:12],
|
||||
len(result.extra),
|
||||
)
|
||||
|
||||
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if refreshed_contact is not None:
|
||||
broadcast_event("contact", refreshed_contact.model_dump())
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Centralized helpers for MeshCore multi-byte path encoding.
|
||||
|
||||
The path_len wire byte is packed as [hash_mode:2][hop_count:6]:
|
||||
- hash_size = (hash_mode) + 1 → 1, 2, or 3 bytes per hop
|
||||
- hop_count = lower 6 bits → 0–63 hops
|
||||
- wire bytes = hop_count × hash_size
|
||||
|
||||
Mode 3 (hash_size=4) is reserved and rejected.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
MAX_PATH_SIZE = 64
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedPacketEnvelope:
|
||||
"""Canonical packet framing parse matching MeshCore Packet::readFrom()."""
|
||||
|
||||
header: int
|
||||
route_type: int
|
||||
payload_type: int
|
||||
payload_version: int
|
||||
path_byte: int
|
||||
hop_count: int
|
||||
hash_size: int
|
||||
path_byte_len: int
|
||||
path: bytes
|
||||
payload: bytes
|
||||
payload_offset: int
|
||||
|
||||
|
||||
def decode_path_byte(path_byte: int) -> tuple[int, int]:
|
||||
"""Decode a packed path byte into (hop_count, hash_size).
|
||||
|
||||
Returns:
|
||||
(hop_count, hash_size) where hash_size is 1, 2, or 3.
|
||||
|
||||
Raises:
|
||||
ValueError: If hash_mode is 3 (reserved).
|
||||
"""
|
||||
hash_mode = (path_byte >> 6) & 0x03
|
||||
if hash_mode == 3:
|
||||
raise ValueError(f"Reserved path hash mode 3 (path_byte=0x{path_byte:02X})")
|
||||
hop_count = path_byte & 0x3F
|
||||
hash_size = hash_mode + 1
|
||||
return hop_count, hash_size
|
||||
|
||||
|
||||
def path_wire_len(hop_count: int, hash_size: int) -> int:
|
||||
"""Wire byte length of path data."""
|
||||
return hop_count * hash_size
|
||||
|
||||
|
||||
def validate_path_byte(path_byte: int) -> tuple[int, int, int]:
|
||||
"""Validate a packed path byte using firmware-equivalent rules.
|
||||
|
||||
Returns:
|
||||
(hop_count, hash_size, byte_len)
|
||||
|
||||
Raises:
|
||||
ValueError: If the encoding uses reserved mode 3 or exceeds MAX_PATH_SIZE.
|
||||
"""
|
||||
hop_count, hash_size = decode_path_byte(path_byte)
|
||||
byte_len = path_wire_len(hop_count, hash_size)
|
||||
if byte_len > MAX_PATH_SIZE:
|
||||
raise ValueError(
|
||||
f"Invalid path length {byte_len} bytes exceeds MAX_PATH_SIZE={MAX_PATH_SIZE}"
|
||||
)
|
||||
return hop_count, hash_size, byte_len
|
||||
|
||||
|
||||
def parse_packet_envelope(raw_packet: bytes) -> ParsedPacketEnvelope | None:
|
||||
"""Parse packet framing using firmware Packet::readFrom() semantics.
|
||||
|
||||
Validation matches the firmware's path checks:
|
||||
- reserved mode 3 is invalid
|
||||
- hop_count * hash_size must not exceed MAX_PATH_SIZE
|
||||
- at least one payload byte must remain after the path
|
||||
"""
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
payload_version = (header >> 6) & 0x03
|
||||
|
||||
offset = 1
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_byte = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
hop_count, hash_size, path_byte_len = validate_path_byte(path_byte)
|
||||
if len(raw_packet) < offset + path_byte_len:
|
||||
return None
|
||||
|
||||
path = raw_packet[offset : offset + path_byte_len]
|
||||
offset += path_byte_len
|
||||
|
||||
if offset >= len(raw_packet):
|
||||
return None
|
||||
|
||||
return ParsedPacketEnvelope(
|
||||
header=header,
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
payload_version=payload_version,
|
||||
path_byte=path_byte,
|
||||
hop_count=hop_count,
|
||||
hash_size=hash_size,
|
||||
path_byte_len=path_byte_len,
|
||||
path=path,
|
||||
payload=raw_packet[offset:],
|
||||
payload_offset=offset,
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def split_path_hex(path_hex: str, hop_count: int) -> list[str]:
|
||||
"""Split a hex path string into per-hop chunks using the known hop count.
|
||||
|
||||
If hop_count is 0 or the hex length doesn't divide evenly, falls back
|
||||
to 2-char (1-byte) chunks for backward compatibility.
|
||||
"""
|
||||
if not path_hex or hop_count <= 0:
|
||||
return []
|
||||
chars_per_hop = len(path_hex) // hop_count
|
||||
if chars_per_hop < 2 or chars_per_hop % 2 != 0 or chars_per_hop * hop_count != len(path_hex):
|
||||
# Inconsistent — fall back to legacy 2-char split
|
||||
return [path_hex[i : i + 2] for i in range(0, len(path_hex), 2)]
|
||||
return [path_hex[i : i + chars_per_hop] for i in range(0, len(path_hex), chars_per_hop)]
|
||||
|
||||
|
||||
def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
|
||||
"""Extract the first hop identifier from a path hex string.
|
||||
|
||||
Returns None for empty/direct paths.
|
||||
"""
|
||||
hops = split_path_hex(path_hex, hop_count)
|
||||
return hops[0] if hops else None
|
||||
|
||||
|
||||
def normalize_contact_route(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
path_hash_mode: int | None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Normalize stored contact route fields.
|
||||
|
||||
Handles legacy/bad rows where the packed wire path byte was stored directly
|
||||
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
||||
Returns `(path_hex, hop_count, hash_mode)`.
|
||||
"""
|
||||
normalized_path = path_hex or ""
|
||||
|
||||
try:
|
||||
normalized_len = int(path_len) if path_len is not None else -1
|
||||
except (TypeError, ValueError):
|
||||
normalized_len = -1
|
||||
|
||||
try:
|
||||
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
|
||||
except (TypeError, ValueError):
|
||||
normalized_mode = None
|
||||
|
||||
if normalized_len < -1 or normalized_len > 63:
|
||||
packed = normalized_len & 0xFF
|
||||
if packed == 0xFF:
|
||||
return "", -1, -1
|
||||
decoded_mode = (packed >> 6) & 0x03
|
||||
if decoded_mode != 0x03:
|
||||
normalized_len = packed & 0x3F
|
||||
normalized_mode = decoded_mode
|
||||
|
||||
if normalized_len == -1:
|
||||
return "", -1, -1
|
||||
|
||||
if normalized_mode not in (0, 1, 2):
|
||||
normalized_mode = 0
|
||||
|
||||
if normalized_path:
|
||||
bytes_per_hop = normalized_mode + 1
|
||||
actual_bytes = len(normalized_path) // 2
|
||||
expected_bytes = normalized_len * bytes_per_hop
|
||||
if actual_bytes > expected_bytes >= 0:
|
||||
normalized_path = normalized_path[: expected_bytes * 2]
|
||||
elif (
|
||||
actual_bytes < expected_bytes
|
||||
and bytes_per_hop > 0
|
||||
and actual_bytes % bytes_per_hop == 0
|
||||
):
|
||||
normalized_len = actual_bytes // bytes_per_hop
|
||||
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def normalize_route_override(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
path_hash_mode: int | None,
|
||||
) -> tuple[str | None, int | None, int | None]:
|
||||
"""Normalize optional route-override fields while preserving the unset state."""
|
||||
if path_len is None:
|
||||
return None, None, None
|
||||
|
||||
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
||||
path_hex,
|
||||
path_len,
|
||||
path_hash_mode,
|
||||
)
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
||||
"""Parse a comma-separated explicit hop route into stored contact fields."""
|
||||
hops = [hop.strip().lower() for hop in route_text.split(",") if hop.strip()]
|
||||
if not hops:
|
||||
raise ValueError("Explicit path must include at least one hop")
|
||||
|
||||
hop_chars = len(hops[0])
|
||||
if hop_chars not in (2, 4, 6):
|
||||
raise ValueError("Each hop must be 1, 2, or 3 bytes of hex")
|
||||
|
||||
for hop in hops:
|
||||
if len(hop) != hop_chars:
|
||||
raise ValueError("All hops must use the same width")
|
||||
try:
|
||||
bytes.fromhex(hop)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Each hop must be valid hex") from exc
|
||||
|
||||
hash_size = hop_chars // 2
|
||||
if path_wire_len(len(hops), hash_size) > MAX_PATH_SIZE:
|
||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||
|
||||
return "".join(hops), len(hops), hash_size - 1
|
||||
|
||||
|
||||
async def bucket_path_hash_widths(cursor, *, batch_size: int = 500) -> dict[str, int | float]:
|
||||
"""Bucket raw packet rows by hop hash width and return counts + percentages.
|
||||
|
||||
*cursor* must be an already-executed async cursor whose rows have a ``data``
|
||||
column containing raw packet bytes.
|
||||
"""
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
|
||||
total = single_byte + double_byte + triple_byte
|
||||
if total == 0:
|
||||
return {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
"double_byte": 0,
|
||||
"triple_byte": 0,
|
||||
"single_byte_pct": 0.0,
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_packets": total,
|
||||
"single_byte": single_byte,
|
||||
"double_byte": double_byte,
|
||||
"triple_byte": triple_byte,
|
||||
"single_byte_pct": (single_byte / total) * 100,
|
||||
"double_byte_pct": (double_byte / total) * 100,
|
||||
"triple_byte_pct": (triple_byte / total) * 100,
|
||||
}
|
||||
+261
-121
@@ -2,14 +2,20 @@ import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore import MeshCore
|
||||
from serial.serialutil import SerialException
|
||||
|
||||
from app.config import settings
|
||||
from app.keystore import clear_keys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
|
||||
_SERIAL_PORT_ERROR_RE = re.compile(r"could not open port (?P<port>.+?):")
|
||||
|
||||
|
||||
class RadioOperationError(RuntimeError):
|
||||
@@ -66,6 +72,36 @@ def detect_serial_devices() -> list[str]:
|
||||
return devices
|
||||
|
||||
|
||||
def _extract_serial_port_from_error(exc: Exception) -> str | None:
|
||||
"""Best-effort extraction of a serial port path from a pyserial error."""
|
||||
message = str(exc)
|
||||
match = _SERIAL_PORT_ERROR_RE.search(message)
|
||||
if match:
|
||||
return match.group("port")
|
||||
return None
|
||||
|
||||
|
||||
def _format_reconnect_failure(exc: Exception) -> tuple[str, str, bool]:
|
||||
"""Return log message, frontend detail, and whether to log a traceback."""
|
||||
if settings.connection_type == "serial":
|
||||
if isinstance(exc, RuntimeError) and str(exc).startswith("No MeshCore radio found"):
|
||||
message = (
|
||||
"Could not find a MeshCore radio on any serial port. "
|
||||
"Did the radio get disconnected or change serial ports?"
|
||||
)
|
||||
return (message, message, False)
|
||||
|
||||
if isinstance(exc, SerialException):
|
||||
port = settings.serial_port or _extract_serial_port_from_error(exc) or "the serial port"
|
||||
message = (
|
||||
f"Could not connect to serial port {port}. "
|
||||
"Did the radio get disconnected or change serial ports?"
|
||||
)
|
||||
return (message, message, False)
|
||||
|
||||
return (f"Reconnection failed: {exc}", str(exc), True)
|
||||
|
||||
|
||||
async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) -> bool:
|
||||
"""Test if a MeshCore radio responds on the given serial port."""
|
||||
mc = None
|
||||
@@ -121,6 +157,7 @@ class RadioManager:
|
||||
def __init__(self):
|
||||
self._meshcore: MeshCore | None = None
|
||||
self._connection_info: str | None = None
|
||||
self._connection_desired: bool = True
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._last_connected: bool = False
|
||||
self._reconnect_lock: asyncio.Lock | None = None
|
||||
@@ -128,6 +165,18 @@ class RadioManager:
|
||||
self._setup_lock: asyncio.Lock | None = None
|
||||
self._setup_in_progress: bool = False
|
||||
self._setup_complete: bool = False
|
||||
self._frontend_reconnect_error_broadcasts: int = 0
|
||||
self.device_info_loaded: bool = False
|
||||
self.max_contacts: int | None = None
|
||||
self.device_model: str | None = None
|
||||
self.firmware_build: str | None = None
|
||||
self.firmware_version: str | None = None
|
||||
self.max_channels: int = 40
|
||||
self.path_hash_mode: int = 0
|
||||
self.path_hash_mode_supported: bool = False
|
||||
self._channel_slot_by_key: OrderedDict[str, int] = OrderedDict()
|
||||
self._channel_key_by_slot: dict[int, str] = {}
|
||||
self._pending_message_channel_key_by_slot: dict[int, str] = {}
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
@@ -157,6 +206,20 @@ class RadioManager:
|
||||
else:
|
||||
logger.error("Attempted to release unlocked radio operation lock (%s)", name)
|
||||
|
||||
def _reset_connected_runtime_state(self) -> None:
|
||||
"""Clear cached runtime state after a transport teardown completes."""
|
||||
self._setup_complete = False
|
||||
self.device_info_loaded = False
|
||||
self.max_contacts = None
|
||||
self.device_model = None
|
||||
self.firmware_build = None
|
||||
self.firmware_version = None
|
||||
self.max_channels = 40
|
||||
self.path_hash_mode = 0
|
||||
self.path_hash_mode_supported = False
|
||||
self.reset_channel_send_cache()
|
||||
self.clear_pending_message_channel_slots()
|
||||
|
||||
@asynccontextmanager
|
||||
async def radio_operation(
|
||||
self,
|
||||
@@ -215,83 +278,125 @@ class RadioManager:
|
||||
self._release_operation_lock(name)
|
||||
|
||||
async def post_connect_setup(self) -> None:
|
||||
"""Full post-connection setup: handlers, key export, sync, advertisements, polling.
|
||||
"""Run shared post-connection orchestration after transport setup succeeds."""
|
||||
from app.services.radio_lifecycle import run_post_connect_setup
|
||||
|
||||
Called after every successful connection or reconnection.
|
||||
Idempotent — safe to call repeatedly (periodic tasks have start guards).
|
||||
await run_post_connect_setup(self)
|
||||
|
||||
def reset_channel_send_cache(self) -> None:
|
||||
"""Forget any session-local channel-slot reuse state."""
|
||||
self._channel_slot_by_key.clear()
|
||||
self._channel_key_by_slot.clear()
|
||||
|
||||
def remember_pending_message_channel_slot(self, channel_key: str, slot: int) -> None:
|
||||
"""Remember a channel key for later queued-message recovery."""
|
||||
self._pending_message_channel_key_by_slot[slot] = channel_key.upper()
|
||||
|
||||
def get_pending_message_channel_key(self, slot: int) -> str | None:
|
||||
"""Return the last remembered channel key for a radio slot."""
|
||||
return self._pending_message_channel_key_by_slot.get(slot)
|
||||
|
||||
def clear_pending_message_channel_slots(self) -> None:
|
||||
"""Drop any queued-message recovery slot metadata."""
|
||||
self._pending_message_channel_key_by_slot.clear()
|
||||
|
||||
def channel_slot_reuse_enabled(self) -> bool:
|
||||
"""Return whether this transport can safely reuse cached channel slots."""
|
||||
if settings.force_channel_slot_reconfigure:
|
||||
return False
|
||||
if self._connection_info:
|
||||
return not self._connection_info.startswith("TCP:")
|
||||
return settings.connection_type != "tcp"
|
||||
|
||||
def get_channel_send_cache_capacity(self) -> int:
|
||||
"""Return the app-managed channel cache capacity for the current session."""
|
||||
try:
|
||||
return max(1, int(self.max_channels))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
def get_cached_channel_slot(self, channel_key: str) -> int | None:
|
||||
"""Return the cached radio slot for a channel key, if present."""
|
||||
return self._channel_slot_by_key.get(channel_key.upper())
|
||||
|
||||
def plan_channel_send_slot(
|
||||
self,
|
||||
channel_key: str,
|
||||
*,
|
||||
preferred_slot: int = 0,
|
||||
) -> tuple[int, bool, str | None]:
|
||||
"""Choose a radio slot for a channel send.
|
||||
|
||||
Returns `(slot, needs_configure, evicted_channel_key)`.
|
||||
"""
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
from app.radio_sync import (
|
||||
drain_pending_messages,
|
||||
send_advertisement,
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return preferred_slot, True, None
|
||||
|
||||
if not self._meshcore:
|
||||
normalized_key = channel_key.upper()
|
||||
cached_slot = self._channel_slot_by_key.get(normalized_key)
|
||||
if cached_slot is not None:
|
||||
return cached_slot, False, None
|
||||
|
||||
capacity = self.get_channel_send_cache_capacity()
|
||||
if len(self._channel_slot_by_key) < capacity:
|
||||
slot = self._find_first_free_channel_slot(capacity, preferred_slot)
|
||||
return slot, True, None
|
||||
|
||||
evicted_key, slot = next(iter(self._channel_slot_by_key.items()))
|
||||
return slot, True, evicted_key
|
||||
|
||||
def note_channel_slot_loaded(self, channel_key: str, slot: int) -> None:
|
||||
"""Record that a channel is now resident in the given radio slot."""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return
|
||||
|
||||
if self._setup_lock is None:
|
||||
self._setup_lock = asyncio.Lock()
|
||||
normalized_key = channel_key.upper()
|
||||
previous_slot = self._channel_slot_by_key.pop(normalized_key, None)
|
||||
if previous_slot is not None and previous_slot != slot:
|
||||
self._channel_key_by_slot.pop(previous_slot, None)
|
||||
|
||||
async with self._setup_lock:
|
||||
if not self._meshcore:
|
||||
return
|
||||
self._setup_in_progress = True
|
||||
self._setup_complete = False
|
||||
mc = self._meshcore
|
||||
try:
|
||||
# Register event handlers (no radio I/O, just callback setup)
|
||||
register_event_handlers(mc)
|
||||
displaced_key = self._channel_key_by_slot.get(slot)
|
||||
if displaced_key is not None and displaced_key != normalized_key:
|
||||
self._channel_slot_by_key.pop(displaced_key, None)
|
||||
|
||||
# Hold the operation lock for all radio I/O during setup.
|
||||
# This prevents user-initiated operations (send message, etc.)
|
||||
# from interleaving commands on the serial link.
|
||||
await self._acquire_operation_lock("post_connect_setup", blocking=True)
|
||||
try:
|
||||
await export_and_store_private_key(mc)
|
||||
self._channel_key_by_slot[slot] = normalized_key
|
||||
self._channel_slot_by_key[normalized_key] = slot
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time(mc)
|
||||
def note_channel_slot_used(self, channel_key: str) -> None:
|
||||
"""Refresh LRU order for a previously loaded channel slot."""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
normalized_key = channel_key.upper()
|
||||
slot = self._channel_slot_by_key.get(normalized_key)
|
||||
if slot is None:
|
||||
return
|
||||
self._channel_slot_by_key.move_to_end(normalized_key)
|
||||
self._channel_key_by_slot[slot] = normalized_key
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
def invalidate_cached_channel_slot(self, channel_key: str) -> None:
|
||||
"""Drop any cached slot assignment for a channel key."""
|
||||
normalized_key = channel_key.upper()
|
||||
slot = self._channel_slot_by_key.pop(normalized_key, None)
|
||||
if slot is None:
|
||||
return
|
||||
if self._channel_key_by_slot.get(slot) == normalized_key:
|
||||
self._channel_key_by_slot.pop(slot, None)
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
def get_channel_send_cache_snapshot(self) -> list[tuple[str, int]]:
|
||||
"""Return the current channel send cache contents in LRU order."""
|
||||
return list(self._channel_slot_by_key.items())
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
self._release_operation_lock("post_connect_setup")
|
||||
def _find_first_free_channel_slot(self, capacity: int, preferred_slot: int) -> int:
|
||||
"""Pick the first unclaimed app-managed slot, preferring the requested slot."""
|
||||
if preferred_slot < capacity and preferred_slot not in self._channel_key_by_slot:
|
||||
return preferred_slot
|
||||
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
for slot in range(capacity):
|
||||
if slot not in self._channel_key_by_slot:
|
||||
return slot
|
||||
|
||||
self._setup_complete = True
|
||||
finally:
|
||||
self._setup_in_progress = False
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
return preferred_slot
|
||||
|
||||
@property
|
||||
def meshcore(self) -> MeshCore | None:
|
||||
@@ -317,6 +422,56 @@ class RadioManager:
|
||||
def is_setup_complete(self) -> bool:
|
||||
return self._setup_complete
|
||||
|
||||
@property
|
||||
def connection_desired(self) -> bool:
|
||||
return self._connection_desired
|
||||
|
||||
def resume_connection(self) -> None:
|
||||
"""Allow connection monitor and manual reconnects to establish transport again."""
|
||||
self._connection_desired = True
|
||||
|
||||
async def pause_connection(self) -> None:
|
||||
"""Stop automatic reconnect attempts and tear down any current transport."""
|
||||
self._connection_desired = False
|
||||
self._last_connected = False
|
||||
await self.disconnect()
|
||||
|
||||
def _reset_reconnect_error_broadcasts(self) -> None:
|
||||
self._frontend_reconnect_error_broadcasts = 0
|
||||
|
||||
def _broadcast_reconnect_error_if_needed(self, details: str) -> None:
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
self._frontend_reconnect_error_broadcasts += 1
|
||||
if self._frontend_reconnect_error_broadcasts > MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
|
||||
return
|
||||
|
||||
if self._frontend_reconnect_error_broadcasts == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
|
||||
details = f"{details} Further reconnect failures will be logged only until a connection succeeds."
|
||||
|
||||
broadcast_error("Reconnection failed", details)
|
||||
|
||||
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
|
||||
"""Disable library-managed reconnects so manual teardown fully releases transport."""
|
||||
connection_manager = getattr(mc, "connection_manager", None)
|
||||
if connection_manager is None:
|
||||
return
|
||||
|
||||
if hasattr(connection_manager, "auto_reconnect"):
|
||||
connection_manager.auto_reconnect = False
|
||||
|
||||
reconnect_task = getattr(connection_manager, "_reconnect_task", None)
|
||||
if reconnect_task is None or not isinstance(reconnect_task, asyncio.Task | asyncio.Future):
|
||||
return
|
||||
|
||||
reconnect_task.cancel()
|
||||
try:
|
||||
await reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
connection_manager._reconnect_task = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio using the configured transport."""
|
||||
if self._meshcore is not None:
|
||||
@@ -393,12 +548,33 @@ class RadioManager:
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the radio."""
|
||||
if self._meshcore is not None:
|
||||
from app.radio_sync import stop_background_contact_reconciliation
|
||||
|
||||
clear_keys()
|
||||
self._reset_reconnect_error_broadcasts()
|
||||
if self._meshcore is None:
|
||||
return
|
||||
|
||||
await stop_background_contact_reconciliation()
|
||||
await self._acquire_operation_lock("disconnect", blocking=True)
|
||||
try:
|
||||
mc = self._meshcore
|
||||
if mc is None:
|
||||
return
|
||||
|
||||
logger.debug("Disconnecting from radio")
|
||||
await self._meshcore.disconnect()
|
||||
self._meshcore = None
|
||||
self._setup_complete = False
|
||||
await self._disable_meshcore_auto_reconnect(mc)
|
||||
try:
|
||||
await mc.disconnect()
|
||||
finally:
|
||||
await self._disable_meshcore_auto_reconnect(mc)
|
||||
|
||||
if self._meshcore is mc:
|
||||
self._meshcore = None
|
||||
self._reset_connected_runtime_state()
|
||||
logger.debug("Radio disconnected")
|
||||
finally:
|
||||
self._release_operation_lock("disconnect")
|
||||
|
||||
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
"""Attempt to reconnect to the radio.
|
||||
@@ -406,13 +582,17 @@ class RadioManager:
|
||||
Returns True if reconnection was successful, False otherwise.
|
||||
Uses a lock to prevent concurrent reconnection attempts.
|
||||
"""
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
# Lazily initialize lock (can't create in __init__ before event loop exists)
|
||||
if self._reconnect_lock is None:
|
||||
self._reconnect_lock = asyncio.Lock()
|
||||
|
||||
async with self._reconnect_lock:
|
||||
if not self._connection_desired:
|
||||
logger.info("Reconnect skipped because connection is paused by operator")
|
||||
return False
|
||||
|
||||
# If we became connected while waiting for the lock (another
|
||||
# reconnect succeeded ahead of us), skip the redundant attempt.
|
||||
if self.is_connected:
|
||||
@@ -425,16 +605,21 @@ class RadioManager:
|
||||
# Disconnect if we have a stale connection
|
||||
if self._meshcore is not None:
|
||||
try:
|
||||
await self._meshcore.disconnect()
|
||||
await self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._meshcore = None
|
||||
|
||||
# Try to connect (will auto-detect if no port specified)
|
||||
await self.connect()
|
||||
|
||||
if not self._connection_desired:
|
||||
logger.info("Reconnect completed after pause request; disconnecting transport")
|
||||
await self.disconnect()
|
||||
return False
|
||||
|
||||
if self.is_connected:
|
||||
logger.info("Radio reconnected successfully at %s", self._connection_info)
|
||||
self._reset_reconnect_error_broadcasts()
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, self._connection_info)
|
||||
return True
|
||||
@@ -443,64 +628,19 @@ class RadioManager:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Reconnection failed: %s", e)
|
||||
broadcast_error("Reconnection failed", str(e))
|
||||
log_message, frontend_detail, include_traceback = _format_reconnect_failure(e)
|
||||
logger.warning(log_message, exc_info=include_traceback)
|
||||
self._broadcast_reconnect_error_if_needed(frontend_detail)
|
||||
return False
|
||||
|
||||
async def start_connection_monitor(self) -> None:
|
||||
"""Start background task to monitor connection and auto-reconnect."""
|
||||
from app.services.radio_lifecycle import connection_monitor_loop
|
||||
|
||||
if self._reconnect_task is not None:
|
||||
return
|
||||
|
||||
async def monitor_loop():
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 5
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
current_connected = self.is_connected
|
||||
|
||||
# Detect status change
|
||||
if self._last_connected and not current_connected:
|
||||
# Connection lost
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, self._connection_info)
|
||||
self._last_connected = False
|
||||
|
||||
if not current_connected:
|
||||
# Attempt reconnection on every loop while disconnected
|
||||
if not self.is_reconnecting and await self.reconnect(
|
||||
broadcast_on_success=False
|
||||
):
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
|
||||
elif not self._last_connected and current_connected:
|
||||
# Connection restored (might have reconnected automatically).
|
||||
# Always run setup before reporting healthy.
|
||||
logger.info("Radio connection restored")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
|
||||
elif current_connected and not self._setup_complete:
|
||||
# Transport connected but setup incomplete — retry
|
||||
logger.info("Retrying post-connect setup...")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Task is being cancelled, exit cleanly
|
||||
break
|
||||
except Exception as e:
|
||||
# Log error but continue monitoring - don't let the monitor die
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
|
||||
self._reconnect_task = asyncio.create_task(monitor_loop())
|
||||
self._reconnect_task = asyncio.create_task(connection_monitor_loop(self))
|
||||
logger.info("Radio connection monitor started")
|
||||
|
||||
async def stop_connection_monitor(self) -> None:
|
||||
|
||||
+1148
-172
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
"""Helpers for normalizing MeshCore flood-scope / region names."""
|
||||
|
||||
|
||||
def normalize_region_scope(scope: str | None) -> str:
|
||||
"""Normalize a user-facing region scope into MeshCore's internal form.
|
||||
|
||||
Region names are now user-facing plain strings like ``Esperance``.
|
||||
Internally, MeshCore still expects hashtag-style names like ``#Esperance``.
|
||||
|
||||
Backward compatibility:
|
||||
- blank/None stays disabled (`""`)
|
||||
- existing leading ``#`` is preserved
|
||||
"""
|
||||
|
||||
stripped = (scope or "").strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
if stripped.startswith("#"):
|
||||
return stripped
|
||||
return f"#{stripped}"
|
||||
@@ -5,8 +5,10 @@ from app.repository.contacts import (
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
)
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.repository.messages import MessageRepository
|
||||
from app.repository.raw_packets import RawPacketRepository
|
||||
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||
|
||||
__all__ = [
|
||||
@@ -16,7 +18,9 @@ __all__ = [
|
||||
"ContactAdvertPathRepository",
|
||||
"ContactNameHistoryRepository",
|
||||
"ContactRepository",
|
||||
"FanoutConfigRepository",
|
||||
"MessageRepository",
|
||||
"RawPacketRepository",
|
||||
"RepeaterTelemetryRepository",
|
||||
"StatisticsRepository",
|
||||
]
|
||||
|
||||
@@ -10,8 +10,8 @@ class ChannelRepository:
|
||||
"""Upsert a channel. Key is 32-char hex string."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio, flood_scope_override)
|
||||
VALUES (?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_hashtag = excluded.is_hashtag,
|
||||
@@ -25,7 +25,11 @@ class ChannelRepository:
|
||||
async def get_by_key(key: str) -> Channel | None:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels WHERE key = ?",
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
(key.upper(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@@ -35,14 +39,21 @@ class ChannelRepository:
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels ORDER BY name"
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
@@ -51,11 +62,24 @@ class ChannelRepository:
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def set_favorite(key: str, value: bool) -> bool:
|
||||
"""Set or clear the favorite flag for a channel. Returns True if row was found."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET favorite = ? WHERE key = ?",
|
||||
(1 if value else 0, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
@@ -79,6 +103,26 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def update_flood_scope_override(key: str, flood_scope_override: str | None) -> bool:
|
||||
"""Set or clear a channel's flood-scope override."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET flood_scope_override = ? WHERE key = ?",
|
||||
(flood_scope_override, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
|
||||
"""Set or clear a channel's path hash mode override."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
|
||||
(path_hash_mode_override, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
|
||||
+399
-53
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
@@ -7,7 +9,11 @@ from app.models import (
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactNameHistory,
|
||||
ContactUpsert,
|
||||
)
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
@@ -21,19 +27,70 @@ class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
|
||||
class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: dict[str, Any]) -> None:
|
||||
def _coerce_contact_upsert(
|
||||
contact: ContactUpsert | Contact | Mapping[str, Any],
|
||||
) -> ContactUpsert:
|
||||
if isinstance(contact, ContactUpsert):
|
||||
return contact
|
||||
if isinstance(contact, Contact):
|
||||
return contact.to_upsert()
|
||||
return ContactUpsert.model_validate(contact)
|
||||
|
||||
@staticmethod
|
||||
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
|
||||
contact_row = ContactRepository._coerce_contact_upsert(contact)
|
||||
if (
|
||||
contact_row.direct_path is None
|
||||
and contact_row.direct_path_len is None
|
||||
and contact_row.direct_path_hash_mode is None
|
||||
):
|
||||
direct_path = None
|
||||
direct_path_len = None
|
||||
direct_path_hash_mode = None
|
||||
else:
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
contact_row.direct_path,
|
||||
contact_row.direct_path_len,
|
||||
contact_row.direct_path_hash_mode,
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
contact_row.route_override_path,
|
||||
contact_row.route_override_len,
|
||||
contact_row.route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
||||
last_advert, lat, lon, last_seen, on_radio, last_contacted,
|
||||
first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
|
||||
direct_path_hash_mode, direct_path_updated_at,
|
||||
route_override_path, route_override_len,
|
||||
route_override_hash_mode,
|
||||
last_advert, lat, lon, last_seen,
|
||||
on_radio, last_contacted, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, contacts.name),
|
||||
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||
flags = excluded.flags,
|
||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
||||
last_path_len = excluded.last_path_len,
|
||||
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
|
||||
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
|
||||
direct_path_hash_mode = COALESCE(
|
||||
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
|
||||
),
|
||||
direct_path_updated_at = COALESCE(
|
||||
excluded.direct_path_updated_at, contacts.direct_path_updated_at
|
||||
),
|
||||
route_override_path = COALESCE(
|
||||
excluded.route_override_path, contacts.route_override_path
|
||||
),
|
||||
route_override_len = COALESCE(
|
||||
excluded.route_override_len, contacts.route_override_len
|
||||
),
|
||||
route_override_hash_mode = COALESCE(
|
||||
excluded.route_override_hash_mode, contacts.route_override_hash_mode
|
||||
),
|
||||
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
|
||||
lat = COALESCE(excluded.lat, contacts.lat),
|
||||
lon = COALESCE(excluded.lon, contacts.lon),
|
||||
@@ -43,19 +100,24 @@ class ContactRepository:
|
||||
first_seen = COALESCE(contacts.first_seen, excluded.first_seen)
|
||||
""",
|
||||
(
|
||||
contact.get("public_key", "").lower(),
|
||||
contact.get("name"),
|
||||
contact.get("type", 0),
|
||||
contact.get("flags", 0),
|
||||
contact.get("last_path"),
|
||||
contact.get("last_path_len", -1),
|
||||
contact.get("last_advert"),
|
||||
contact.get("lat"),
|
||||
contact.get("lon"),
|
||||
contact.get("last_seen", int(time.time())),
|
||||
contact.get("on_radio"),
|
||||
contact.get("last_contacted"),
|
||||
contact.get("first_seen"),
|
||||
contact_row.public_key.lower(),
|
||||
contact_row.name,
|
||||
contact_row.type,
|
||||
contact_row.flags,
|
||||
direct_path,
|
||||
direct_path_len,
|
||||
direct_path_hash_mode,
|
||||
contact_row.direct_path_updated_at,
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
contact_row.last_advert,
|
||||
contact_row.lat,
|
||||
contact_row.lon,
|
||||
contact_row.last_seen if contact_row.last_seen is not None else int(time.time()),
|
||||
contact_row.on_radio,
|
||||
contact_row.last_contacted,
|
||||
contact_row.first_seen,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
@@ -63,18 +125,52 @@ class ContactRepository:
|
||||
@staticmethod
|
||||
def _row_to_contact(row) -> Contact:
|
||||
"""Convert a database row to a Contact model."""
|
||||
available_columns = set(row.keys())
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
row["direct_path"] if "direct_path" in available_columns else None,
|
||||
row["direct_path_len"] if "direct_path_len" in available_columns else None,
|
||||
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
|
||||
)
|
||||
route_override_path = (
|
||||
row["route_override_path"] if "route_override_path" in available_columns else None
|
||||
)
|
||||
route_override_len = (
|
||||
row["route_override_len"] if "route_override_len" in available_columns else None
|
||||
)
|
||||
route_override_hash_mode = (
|
||||
row["route_override_hash_mode"]
|
||||
if "route_override_hash_mode" in available_columns
|
||||
else None
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
return Contact(
|
||||
public_key=row["public_key"],
|
||||
name=row["name"],
|
||||
type=row["type"],
|
||||
flags=row["flags"],
|
||||
last_path=row["last_path"],
|
||||
last_path_len=row["last_path_len"],
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
direct_path_updated_at=(
|
||||
row["direct_path_updated_at"]
|
||||
if "direct_path_updated_at" in available_columns
|
||||
else None
|
||||
),
|
||||
route_override_path=route_override_path,
|
||||
route_override_len=route_override_len,
|
||||
route_override_hash_mode=route_override_hash_mode,
|
||||
last_advert=row["last_advert"],
|
||||
lat=row["lat"],
|
||||
lon=row["lon"],
|
||||
last_seen=row["last_seen"],
|
||||
on_radio=bool(row["on_radio"]),
|
||||
favorite=bool(row["favorite"]) if "favorite" in available_columns else False,
|
||||
last_contacted=row["last_contacted"],
|
||||
last_read_at=row["last_read_at"],
|
||||
first_seen=row["first_seen"],
|
||||
@@ -96,6 +192,9 @@ class ContactRepository:
|
||||
the prefix (to avoid silently selecting the wrong contact).
|
||||
"""
|
||||
normalized_prefix = prefix.lower()
|
||||
exact = await ContactRepository.get_by_key(normalized_prefix)
|
||||
if exact:
|
||||
return exact
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
|
||||
(f"{normalized_prefix}%",),
|
||||
@@ -181,17 +280,13 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get the most recently active non-repeater contacts.
|
||||
|
||||
Orders by most recent activity (last_contacted or last_advert),
|
||||
excluding repeaters (type=2).
|
||||
"""
|
||||
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently interacted-with non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2
|
||||
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
@@ -200,30 +295,128 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update_path(public_key: str, path: str, path_len: int) -> None:
|
||||
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently advert-heard non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_advert DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update_direct_path(
|
||||
public_key: str,
|
||||
path: str,
|
||||
path_len: int,
|
||||
path_hash_mode: int | None = None,
|
||||
updated_at: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
|
||||
path,
|
||||
path_len,
|
||||
path_hash_mode,
|
||||
)
|
||||
ts = updated_at if updated_at is not None else int(time.time())
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
|
||||
(path, path_len, int(time.time()), public_key.lower()),
|
||||
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
|
||||
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
|
||||
direct_path_updated_at = ?,
|
||||
last_seen = ? WHERE public_key = ?""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_path_len,
|
||||
normalized_hash_mode,
|
||||
ts,
|
||||
ts,
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
async def set_routing_override(
|
||||
public_key: str,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||
path,
|
||||
path_len,
|
||||
path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
|
||||
(on_radio, public_key.lower()),
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = ?, route_override_len = ?, route_override_hash_mode = ?
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_len,
|
||||
normalized_hash_mode,
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_routing_override(public_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = NULL,
|
||||
route_override_len = NULL,
|
||||
route_override_hash_mode = NULL
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_on_radio_except(keep_keys: list[str]) -> None:
|
||||
"""Set on_radio=False for all contacts NOT in keep_keys."""
|
||||
if not keep_keys:
|
||||
await db.conn.execute("UPDATE contacts SET on_radio = 0 WHERE on_radio = 1")
|
||||
else:
|
||||
placeholders = ",".join("?" * len(keep_keys))
|
||||
await db.conn.execute(
|
||||
f"UPDATE contacts SET on_radio = 0 WHERE on_radio = 1 AND public_key NOT IN ({placeholders})",
|
||||
keep_keys,
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_favorites() -> list[Contact]:
|
||||
"""Return all contacts marked as favorite."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def set_favorite(public_key: str, value: bool) -> None:
|
||||
"""Set or clear the favorite flag for a contact."""
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET favorite = ? WHERE public_key = ?",
|
||||
(1 if value else 0, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
normalized = public_key.lower()
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
# contact_name_history and contact_advert_paths cascade via FK.
|
||||
# Messages are intentionally preserved so history re-surfaces
|
||||
# if the contact is re-added later.
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
||||
await db.conn.commit()
|
||||
|
||||
@@ -251,6 +444,151 @@ class ContactRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def promote_prefix_placeholders(full_key: str) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts to a resolved full key.
|
||||
|
||||
Returns the placeholder public keys that were merged into the full key.
|
||||
"""
|
||||
|
||||
async def migrate_child_rows(old_key: str, new_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||
SELECT ?, name, first_seen, last_seen
|
||||
FROM contact_name_history
|
||||
WHERE public_key = ?
|
||||
ON CONFLICT(public_key, name) DO UPDATE SET
|
||||
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
|
||||
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
|
||||
""",
|
||||
(new_key, old_key),
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_advert_paths
|
||||
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
|
||||
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
|
||||
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||
heard_count = contact_advert_paths.heard_count + excluded.heard_count
|
||||
""",
|
||||
(new_key, old_key),
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_name_history WHERE public_key = ?",
|
||||
(old_key,),
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_advert_paths WHERE public_key = ?",
|
||||
(old_key,),
|
||||
)
|
||||
|
||||
normalized_full_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_seen, last_contacted, first_seen, last_read_at
|
||||
FROM contacts
|
||||
WHERE length(public_key) < 64
|
||||
AND ? LIKE public_key || '%'
|
||||
ORDER BY length(public_key) DESC, public_key
|
||||
""",
|
||||
(normalized_full_key,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
promoted_keys: list[str] = []
|
||||
|
||||
for row in rows:
|
||||
old_key = row["public_key"]
|
||||
if old_key == normalized_full_key:
|
||||
continue
|
||||
|
||||
match_cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS match_count
|
||||
FROM contacts
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE ? || '%'
|
||||
""",
|
||||
(old_key,),
|
||||
)
|
||||
match_row = await match_cursor.fetchone()
|
||||
match_count = match_row["match_count"] if match_row is not None else 0
|
||||
if match_count != 1:
|
||||
logger.warning(
|
||||
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
|
||||
old_key,
|
||||
match_count,
|
||||
)
|
||||
continue
|
||||
|
||||
await migrate_child_rows(old_key, normalized_full_key)
|
||||
|
||||
# Merge timestamp metadata from the old prefix contact into the
|
||||
# full-key contact (which all callers guarantee already exists),
|
||||
# then delete the prefix placeholder.
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = CASE
|
||||
WHEN contacts.last_read_at IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_read_at
|
||||
WHEN ? > contacts.last_read_at THEN ?
|
||||
ELSE contacts.last_read_at
|
||||
END
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
|
||||
promoted_keys.append(old_key)
|
||||
|
||||
await db.conn.commit()
|
||||
return promoted_keys
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
@@ -274,10 +612,11 @@ class ContactAdvertPathRepository:
|
||||
@staticmethod
|
||||
def _row_to_path(row) -> ContactAdvertPath:
|
||||
path = row["path_hex"] or ""
|
||||
next_hop = path[:2].lower() if len(path) >= 2 else None
|
||||
path_len = row["path_len"]
|
||||
next_hop = first_hop_hex(path, path_len)
|
||||
return ContactAdvertPath(
|
||||
path=path,
|
||||
path_len=row["path_len"],
|
||||
path_len=path_len,
|
||||
next_hop=next_hop,
|
||||
first_seen=row["first_seen"],
|
||||
last_seen=row["last_seen"],
|
||||
@@ -290,6 +629,7 @@ class ContactAdvertPathRepository:
|
||||
path_hex: str,
|
||||
timestamp: int,
|
||||
max_paths: int = 10,
|
||||
hop_count: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Upsert a unique advert path observation for a contact and prune to N most recent.
|
||||
@@ -299,16 +639,15 @@ class ContactAdvertPathRepository:
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
normalized_path = path_hex.lower()
|
||||
path_len = len(normalized_path) // 2
|
||||
path_len = hop_count if hop_count is not None else len(normalized_path) // 2
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_advert_paths
|
||||
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(public_key, path_hex) DO UPDATE SET
|
||||
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
|
||||
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||
path_len = excluded.path_len,
|
||||
heard_count = contact_advert_paths.heard_count + 1
|
||||
""",
|
||||
(normalized_key, normalized_path, path_len, timestamp, timestamp),
|
||||
@@ -319,8 +658,8 @@ class ContactAdvertPathRepository:
|
||||
"""
|
||||
DELETE FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
AND path_hex NOT IN (
|
||||
SELECT path_hex
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
@@ -353,9 +692,18 @@ class ContactAdvertPathRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
FROM (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY public_key
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
) AS rn
|
||||
FROM contact_advert_paths
|
||||
)
|
||||
WHERE rn <= ?
|
||||
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
"""
|
||||
""",
|
||||
(limit_per_contact,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
@@ -366,8 +714,6 @@ class ContactAdvertPathRepository:
|
||||
if paths is None:
|
||||
paths = []
|
||||
grouped[key] = paths
|
||||
if len(paths) >= limit_per_contact:
|
||||
continue
|
||||
paths.append(ContactAdvertPathRepository._row_to_path(row))
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Repository for fanout_configs table."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory cache of config metadata (name, type) for status reporting.
|
||||
# Populated by get_all/get/create/update and read by FanoutManager.get_statuses().
|
||||
_configs_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
"""Convert a database row to a config dict."""
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
"name": row["name"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"config": json.loads(row["config"]) if row["config"] else {},
|
||||
"scope": json.loads(row["scope"]) if row["scope"] else {},
|
||||
"sort_order": row["sort_order"] or 0,
|
||||
"created_at": row["created_at"] or 0,
|
||||
}
|
||||
_configs_cache[result["id"]] = result
|
||||
return result
|
||||
|
||||
|
||||
class FanoutConfigRepository:
|
||||
"""CRUD operations for fanout_configs table."""
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[dict[str, Any]]:
|
||||
"""Get all fanout configs ordered by sort_order."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM fanout_configs ORDER BY sort_order, created_at"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get(config_id: str) -> dict[str, Any] | None:
|
||||
"""Get a single fanout config by ID."""
|
||||
cursor = await db.conn.execute("SELECT * FROM fanout_configs WHERE id = ?", (config_id,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
config_type: str,
|
||||
name: str,
|
||||
config: dict,
|
||||
scope: dict,
|
||||
enabled: bool = True,
|
||||
config_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new fanout config."""
|
||||
new_id = config_id or str(uuid.uuid4())
|
||||
now = int(time.time())
|
||||
|
||||
# Get next sort_order
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM fanout_configs"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
sort_order = row[0] if row else 0
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
config_type,
|
||||
name,
|
||||
1 if enabled else 0,
|
||||
json.dumps(config),
|
||||
json.dumps(scope),
|
||||
sort_order,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
result = await FanoutConfigRepository.get(new_id)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def update(config_id: str, **fields: Any) -> dict[str, Any] | None:
|
||||
"""Update a fanout config. Only provided fields are updated."""
|
||||
updates = []
|
||||
params: list[Any] = []
|
||||
|
||||
for field in ("name", "enabled", "config", "scope", "sort_order"):
|
||||
if field in fields:
|
||||
value = fields[field]
|
||||
if field == "enabled":
|
||||
value = 1 if value else 0
|
||||
elif field in ("config", "scope"):
|
||||
value = json.dumps(value)
|
||||
updates.append(f"{field} = ?")
|
||||
params.append(value)
|
||||
|
||||
if not updates:
|
||||
return await FanoutConfigRepository.get(config_id)
|
||||
|
||||
params.append(config_id)
|
||||
query = f"UPDATE fanout_configs SET {', '.join(updates)} WHERE id = ?"
|
||||
await db.conn.execute(query, params)
|
||||
await db.conn.commit()
|
||||
|
||||
return await FanoutConfigRepository.get(config_id)
|
||||
|
||||
@staticmethod
|
||||
async def delete(config_id: str) -> None:
|
||||
"""Delete a fanout config."""
|
||||
await db.conn.execute("DELETE FROM fanout_configs WHERE id = ?", (config_id,))
|
||||
await db.conn.commit()
|
||||
_configs_cache.pop(config_id, None)
|
||||
|
||||
@staticmethod
|
||||
async def get_enabled() -> list[dict[str, Any]]:
|
||||
"""Get all enabled fanout configs."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM fanout_configs WHERE enabled = 1 ORDER BY sort_order, created_at"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
+742
-108
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from hashlib import sha256
|
||||
|
||||
from app.database import db
|
||||
@@ -8,6 +8,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDECRYPTED_PACKET_BATCH_SIZE = 500
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
@@ -32,46 +34,23 @@ class RawPacketRepository:
|
||||
# For malformed packets, hash the full data
|
||||
payload_hash = sha256(data).digest()
|
||||
|
||||
# Check if this payload already exists
|
||||
cursor = await db.conn.execute(
|
||||
"INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(ts, data, payload_hash),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
assert cursor.lastrowid is not None
|
||||
return (cursor.lastrowid, True)
|
||||
|
||||
# Duplicate payload — look up the existing row.
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Duplicate - return existing packet ID
|
||||
logger.debug(
|
||||
"Duplicate payload detected (hash=%s..., existing_id=%d)",
|
||||
payload_hash.hex()[:12],
|
||||
existing["id"],
|
||||
)
|
||||
return (existing["id"], False)
|
||||
|
||||
# New packet - insert with hash
|
||||
try:
|
||||
cursor = await db.conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(ts, data, payload_hash),
|
||||
)
|
||||
await db.conn.commit()
|
||||
assert cursor.lastrowid is not None # INSERT always returns a row ID
|
||||
return (cursor.lastrowid, True)
|
||||
except sqlite3.IntegrityError:
|
||||
# Race condition: another insert with same payload_hash happened between
|
||||
# our SELECT and INSERT. This is expected for duplicate packets arriving
|
||||
# close together. Query again to get the existing ID.
|
||||
logger.debug(
|
||||
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
|
||||
payload_hash.hex()[:16],
|
||||
)
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
return (existing["id"], False)
|
||||
# This shouldn't happen, but if it does, re-raise
|
||||
raise
|
||||
assert existing is not None
|
||||
return (existing["id"], False)
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_count() -> int:
|
||||
@@ -92,13 +71,56 @@ class RawPacketRepository:
|
||||
return row["oldest"] if row and row["oldest"] is not None else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
||||
async def stream_all_undecrypted(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||
"""Yield all undecrypted packets as (id, data, timestamp) in bounded batches."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||
try:
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
yield (row["id"], bytes(row["data"]), row["timestamp"])
|
||||
finally:
|
||||
await cursor.close()
|
||||
|
||||
@staticmethod
|
||||
async def stream_undecrypted_text_messages(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||
"""Yield undecrypted TEXT_MESSAGE packets in bounded-size batches."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
yield (row["id"], data, row["timestamp"])
|
||||
finally:
|
||||
await cursor.close()
|
||||
|
||||
@staticmethod
|
||||
async def count_undecrypted_text_messages(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> int:
|
||||
"""Count undecrypted TEXT_MESSAGE packets without materializing them all."""
|
||||
count = 0
|
||||
async for _packet in RawPacketRepository.stream_undecrypted_text_messages(
|
||||
batch_size=batch_size
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
@@ -109,6 +131,30 @@ class RawPacketRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_linked_message_id(packet_id: int) -> int | None:
|
||||
"""Return the linked message ID for a raw packet, if any."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT message_id FROM raw_packets WHERE id = ?",
|
||||
(packet_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return row["message_id"]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None:
|
||||
"""Return a raw packet row as (id, data, timestamp, message_id)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?",
|
||||
(packet_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"])
|
||||
|
||||
@staticmethod
|
||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||
@@ -126,25 +172,3 @@ class RawPacketRepository:
|
||||
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
||||
|
||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||
These are direct messages that can be decrypted with contact ECDH keys.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
# Filter for TEXT_MESSAGE packets
|
||||
result = []
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
result.append((row["id"], data, row["timestamp"]))
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum age for telemetry history entries (30 days)
|
||||
_MAX_AGE_SECONDS = 30 * 86400
|
||||
|
||||
# Maximum entries to keep per repeater (sanity cap)
|
||||
_MAX_ENTRIES_PER_REPEATER = 1000
|
||||
|
||||
|
||||
class RepeaterTelemetryRepository:
|
||||
@staticmethod
|
||||
async def record(
|
||||
public_key: str,
|
||||
timestamp: int,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Insert a telemetry history row and prune stale entries."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO repeater_telemetry_history
|
||||
(public_key, timestamp, data)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(public_key, timestamp, json.dumps(data)),
|
||||
)
|
||||
|
||||
# Prune entries older than 30 days
|
||||
cutoff = int(time.time()) - _MAX_AGE_SECONDS
|
||||
await db.conn.execute(
|
||||
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
|
||||
(public_key, cutoff),
|
||||
)
|
||||
|
||||
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
DELETE FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND id NOT IN (
|
||||
SELECT id FROM repeater_telemetry_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
|
||||
)
|
||||
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
|
||||
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT timestamp, data
|
||||
FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
""",
|
||||
(public_key, since_timestamp),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"timestamp": row["timestamp"],
|
||||
"data": json.loads(row["data"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
+183
-213
@@ -1,16 +1,19 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, BotConfig, Favorite
|
||||
from app.models import AppSettings
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECONDS_1H = 3600
|
||||
SECONDS_24H = 86400
|
||||
SECONDS_72H = 259200
|
||||
SECONDS_7D = 604800
|
||||
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||
|
||||
|
||||
class AppSettingsRepository:
|
||||
@@ -24,15 +27,11 @@ class AppSettingsRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time, bots,
|
||||
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
|
||||
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
|
||||
mqtt_publish_messages, mqtt_publish_raw_packets,
|
||||
community_mqtt_enabled, community_mqtt_iata,
|
||||
community_mqtt_broker_host, community_mqtt_broker_port,
|
||||
community_mqtt_email
|
||||
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
|
||||
last_message_times,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
tracked_telemetry_repeaters, auto_resend_channel
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -42,20 +41,6 @@ class AppSettingsRepository:
|
||||
# Should not happen after migration, but handle gracefully
|
||||
return AppSettings()
|
||||
|
||||
# Parse favorites JSON
|
||||
favorites = []
|
||||
if row["favorites"]:
|
||||
try:
|
||||
favorites_data = json.loads(row["favorites"])
|
||||
favorites = [Favorite(**f) for f in favorites_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["favorites"][:100] if row["favorites"] else None,
|
||||
)
|
||||
favorites = []
|
||||
|
||||
# Parse last_message_times JSON
|
||||
last_message_times: dict[str, int] = {}
|
||||
if row["last_message_times"]:
|
||||
@@ -68,77 +53,72 @@ class AppSettingsRepository:
|
||||
)
|
||||
last_message_times = {}
|
||||
|
||||
# Parse bots JSON
|
||||
bots: list[BotConfig] = []
|
||||
if row["bots"]:
|
||||
# Parse blocked_keys JSON
|
||||
blocked_keys: list[str] = []
|
||||
if row["blocked_keys"]:
|
||||
try:
|
||||
bots_data = json.loads(row["bots"])
|
||||
bots = [BotConfig(**b) for b in bots_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse bots JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["bots"][:100] if row["bots"] else None,
|
||||
)
|
||||
bots = []
|
||||
blocked_keys = json.loads(row["blocked_keys"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_keys = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
if sort_order not in ("recent", "alpha"):
|
||||
sort_order = "recent"
|
||||
# Parse blocked_names JSON
|
||||
blocked_names: list[str] = []
|
||||
if row["blocked_names"]:
|
||||
try:
|
||||
blocked_names = json.loads(row["blocked_names"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_names = []
|
||||
|
||||
# Parse discovery_blocked_types JSON
|
||||
discovery_blocked_types: list[int] = []
|
||||
if row["discovery_blocked_types"]:
|
||||
try:
|
||||
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
discovery_blocked_types = []
|
||||
|
||||
# Parse tracked_telemetry_repeaters JSON
|
||||
tracked_telemetry_repeaters: list[str] = []
|
||||
try:
|
||||
raw_tracked = row["tracked_telemetry_repeaters"]
|
||||
if raw_tracked:
|
||||
tracked_telemetry_repeaters = json.loads(raw_tracked)
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
tracked_telemetry_repeaters = []
|
||||
|
||||
# Parse auto_resend_channel boolean
|
||||
try:
|
||||
auto_resend_channel = bool(row["auto_resend_channel"])
|
||||
except (KeyError, TypeError):
|
||||
auto_resend_channel = False
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
sidebar_sort_order=sort_order,
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
bots=bots,
|
||||
mqtt_broker_host=row["mqtt_broker_host"] or "",
|
||||
mqtt_broker_port=row["mqtt_broker_port"] or 1883,
|
||||
mqtt_username=row["mqtt_username"] or "",
|
||||
mqtt_password=row["mqtt_password"] or "",
|
||||
mqtt_use_tls=bool(row["mqtt_use_tls"]),
|
||||
mqtt_tls_insecure=bool(row["mqtt_tls_insecure"]),
|
||||
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
|
||||
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
|
||||
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
|
||||
community_mqtt_enabled=bool(row["community_mqtt_enabled"]),
|
||||
community_mqtt_iata=row["community_mqtt_iata"] or "",
|
||||
community_mqtt_broker_host=row["community_mqtt_broker_host"]
|
||||
or "mqtt-us-v1.letsmesh.net",
|
||||
community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
|
||||
community_mqtt_email=row["community_mqtt_email"] or "",
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||
auto_resend_channel=auto_resend_channel,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
sidebar_sort_order: str | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
bots: list[BotConfig] | None = None,
|
||||
mqtt_broker_host: str | None = None,
|
||||
mqtt_broker_port: int | None = None,
|
||||
mqtt_username: str | None = None,
|
||||
mqtt_password: str | None = None,
|
||||
mqtt_use_tls: bool | None = None,
|
||||
mqtt_tls_insecure: bool | None = None,
|
||||
mqtt_topic_prefix: str | None = None,
|
||||
mqtt_publish_messages: bool | None = None,
|
||||
mqtt_publish_raw_packets: bool | None = None,
|
||||
community_mqtt_enabled: bool | None = None,
|
||||
community_mqtt_iata: str | None = None,
|
||||
community_mqtt_broker_host: str | None = None,
|
||||
community_mqtt_broker_port: int | None = None,
|
||||
community_mqtt_email: str | None = None,
|
||||
flood_scope: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
discovery_blocked_types: list[int] | None = None,
|
||||
tracked_telemetry_repeaters: list[str] | None = None,
|
||||
auto_resend_channel: bool | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -148,27 +128,14 @@ class AppSettingsRepository:
|
||||
updates.append("max_radio_contacts = ?")
|
||||
params.append(max_radio_contacts)
|
||||
|
||||
if favorites is not None:
|
||||
updates.append("favorites = ?")
|
||||
favorites_json = json.dumps([f.model_dump() for f in favorites])
|
||||
params.append(favorites_json)
|
||||
|
||||
if auto_decrypt_dm_on_advert is not None:
|
||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
||||
|
||||
if sidebar_sort_order is not None:
|
||||
updates.append("sidebar_sort_order = ?")
|
||||
params.append(sidebar_sort_order)
|
||||
|
||||
if last_message_times is not None:
|
||||
updates.append("last_message_times = ?")
|
||||
params.append(json.dumps(last_message_times))
|
||||
|
||||
if preferences_migrated is not None:
|
||||
updates.append("preferences_migrated = ?")
|
||||
params.append(1 if preferences_migrated else 0)
|
||||
|
||||
if advert_interval is not None:
|
||||
updates.append("advert_interval = ?")
|
||||
params.append(advert_interval)
|
||||
@@ -177,66 +144,29 @@ class AppSettingsRepository:
|
||||
updates.append("last_advert_time = ?")
|
||||
params.append(last_advert_time)
|
||||
|
||||
if bots is not None:
|
||||
updates.append("bots = ?")
|
||||
bots_json = json.dumps([b.model_dump() for b in bots])
|
||||
params.append(bots_json)
|
||||
if flood_scope is not None:
|
||||
updates.append("flood_scope = ?")
|
||||
params.append(flood_scope)
|
||||
|
||||
if mqtt_broker_host is not None:
|
||||
updates.append("mqtt_broker_host = ?")
|
||||
params.append(mqtt_broker_host)
|
||||
if blocked_keys is not None:
|
||||
updates.append("blocked_keys = ?")
|
||||
params.append(json.dumps(blocked_keys))
|
||||
|
||||
if mqtt_broker_port is not None:
|
||||
updates.append("mqtt_broker_port = ?")
|
||||
params.append(mqtt_broker_port)
|
||||
if blocked_names is not None:
|
||||
updates.append("blocked_names = ?")
|
||||
params.append(json.dumps(blocked_names))
|
||||
|
||||
if mqtt_username is not None:
|
||||
updates.append("mqtt_username = ?")
|
||||
params.append(mqtt_username)
|
||||
if discovery_blocked_types is not None:
|
||||
updates.append("discovery_blocked_types = ?")
|
||||
params.append(json.dumps(discovery_blocked_types))
|
||||
|
||||
if mqtt_password is not None:
|
||||
updates.append("mqtt_password = ?")
|
||||
params.append(mqtt_password)
|
||||
if tracked_telemetry_repeaters is not None:
|
||||
updates.append("tracked_telemetry_repeaters = ?")
|
||||
params.append(json.dumps(tracked_telemetry_repeaters))
|
||||
|
||||
if mqtt_use_tls is not None:
|
||||
updates.append("mqtt_use_tls = ?")
|
||||
params.append(1 if mqtt_use_tls else 0)
|
||||
|
||||
if mqtt_tls_insecure is not None:
|
||||
updates.append("mqtt_tls_insecure = ?")
|
||||
params.append(1 if mqtt_tls_insecure else 0)
|
||||
|
||||
if mqtt_topic_prefix is not None:
|
||||
updates.append("mqtt_topic_prefix = ?")
|
||||
params.append(mqtt_topic_prefix)
|
||||
|
||||
if mqtt_publish_messages is not None:
|
||||
updates.append("mqtt_publish_messages = ?")
|
||||
params.append(1 if mqtt_publish_messages else 0)
|
||||
|
||||
if mqtt_publish_raw_packets is not None:
|
||||
updates.append("mqtt_publish_raw_packets = ?")
|
||||
params.append(1 if mqtt_publish_raw_packets else 0)
|
||||
|
||||
if community_mqtt_enabled is not None:
|
||||
updates.append("community_mqtt_enabled = ?")
|
||||
params.append(1 if community_mqtt_enabled else 0)
|
||||
|
||||
if community_mqtt_iata is not None:
|
||||
updates.append("community_mqtt_iata = ?")
|
||||
params.append(community_mqtt_iata)
|
||||
|
||||
if community_mqtt_broker_host is not None:
|
||||
updates.append("community_mqtt_broker_host = ?")
|
||||
params.append(community_mqtt_broker_host)
|
||||
|
||||
if community_mqtt_broker_port is not None:
|
||||
updates.append("community_mqtt_broker_port = ?")
|
||||
params.append(community_mqtt_broker_port)
|
||||
|
||||
if community_mqtt_email is not None:
|
||||
updates.append("community_mqtt_email = ?")
|
||||
params.append(community_mqtt_email)
|
||||
if auto_resend_channel is not None:
|
||||
updates.append("auto_resend_channel = ?")
|
||||
params.append(1 if auto_resend_channel else 0)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
@@ -246,61 +176,48 @@ class AppSettingsRepository:
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@staticmethod
|
||||
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Add a favorite, avoiding duplicates."""
|
||||
async def toggle_blocked_key(key: str) -> AppSettings:
|
||||
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
|
||||
normalized = key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
# Check if already favorited
|
||||
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
|
||||
return settings
|
||||
|
||||
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
if normalized in settings.blocked_keys:
|
||||
new_keys = [k for k in settings.blocked_keys if k != normalized]
|
||||
else:
|
||||
new_keys = settings.blocked_keys + [normalized]
|
||||
return await AppSettingsRepository.update(blocked_keys=new_keys)
|
||||
|
||||
@staticmethod
|
||||
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Remove a favorite."""
|
||||
async def toggle_blocked_name(name: str) -> AppSettings:
|
||||
"""Toggle a display name in the blocked list."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
new_favorites = [
|
||||
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
|
||||
]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def migrate_preferences_from_frontend(
|
||||
favorites: list[dict],
|
||||
sort_order: str,
|
||||
last_message_times: dict[str, int],
|
||||
) -> tuple[AppSettings, bool]:
|
||||
"""Migrate all preferences from frontend localStorage.
|
||||
|
||||
This is a one-time migration. If already migrated, returns current settings
|
||||
without overwriting. Returns (settings, did_migrate) tuple.
|
||||
"""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
if settings.preferences_migrated:
|
||||
# Already migrated, don't overwrite
|
||||
return settings, False
|
||||
|
||||
# Convert frontend favorites format to Favorite objects
|
||||
new_favorites = []
|
||||
for f in favorites:
|
||||
if f.get("type") in ("channel", "contact") and f.get("id"):
|
||||
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
|
||||
|
||||
# Update with migrated preferences and mark as migrated
|
||||
settings = await AppSettingsRepository.update(
|
||||
favorites=new_favorites,
|
||||
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=True,
|
||||
)
|
||||
|
||||
return settings, True
|
||||
if name in settings.blocked_names:
|
||||
new_names = [n for n in settings.blocked_names if n != name]
|
||||
else:
|
||||
new_names = settings.blocked_names + [name]
|
||||
return await AppSettingsRepository.update(blocked_names=new_names)
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
async def get_database_message_totals() -> dict[str, int]:
|
||||
"""Return message totals needed by lightweight debug surfaces."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms,
|
||||
SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages,
|
||||
SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing
|
||||
FROM messages
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
return {
|
||||
"total_dms": row["total_dms"] or 0,
|
||||
"total_channel_messages": row["total_channel_messages"] or 0,
|
||||
"total_outgoing": row["total_outgoing"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||
@@ -325,6 +242,68 @@ class StatisticsRepository:
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _known_channels_active() -> dict[str, int]:
|
||||
"""Count known channel keys with any traffic in each time window.
|
||||
|
||||
Channel keys are stored canonically as uppercase hex, so we can avoid
|
||||
the old UPPER(...) join and aggregate per known channel directly.
|
||||
"""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
WITH known AS (
|
||||
SELECT conversation_key, MAX(received_at) AS last_received_at
|
||||
FROM messages
|
||||
WHERE type = 'CHAN'
|
||||
AND conversation_key IN (SELECT key FROM channels)
|
||||
GROUP BY conversation_key
|
||||
)
|
||||
SELECT
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week
|
||||
FROM known
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
return {
|
||||
"last_hour": row["last_hour"] or 0,
|
||||
"last_24_hours": row["last_24_hours"] or 0,
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _packets_per_hour_72h() -> list[dict[str, int]]:
|
||||
"""Return packet counts bucketed by hour for the last 72 hours."""
|
||||
now = int(time.time())
|
||||
cutoff = now - SECONDS_72H
|
||||
# Bucket timestamps to the start of each hour
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count
|
||||
FROM raw_packets
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY hour_ts
|
||||
ORDER BY hour_ts
|
||||
""",
|
||||
(cutoff,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
"""Aggregate all statistics from existing tables."""
|
||||
@@ -384,26 +363,14 @@ class StatisticsRepository:
|
||||
decrypted_packets = pkt_row["decrypted"] or 0
|
||||
undecrypted_packets = total_packets - decrypted_packets
|
||||
|
||||
# Message type counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_dms: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_channel_messages: int = row["cnt"]
|
||||
|
||||
# Outgoing count
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_outgoing: int = row["cnt"]
|
||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
|
||||
|
||||
return {
|
||||
"busiest_channels_24h": busiest_channels_24h,
|
||||
@@ -413,9 +380,12 @@ class StatisticsRepository:
|
||||
"total_packets": total_packets,
|
||||
"decrypted_packets": decrypted_packets,
|
||||
"undecrypted_packets": undecrypted_packets,
|
||||
"total_dms": total_dms,
|
||||
"total_channel_messages": total_channel_messages,
|
||||
"total_outgoing": total_outgoing,
|
||||
"total_dms": message_totals["total_dms"],
|
||||
"total_channel_messages": message_totals["total_channel_messages"],
|
||||
"total_outgoing": message_totals["total_outgoing"],
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
"packets_per_hour_72h": packets_per_hour_72h,
|
||||
}
|
||||
|
||||
+328
-57
@@ -1,20 +1,31 @@
|
||||
import logging
|
||||
import re
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.repository import ChannelRepository
|
||||
from app.channel_constants import (
|
||||
PUBLIC_CHANNEL_KEY,
|
||||
PUBLIC_CHANNEL_NAME,
|
||||
is_public_channel_key,
|
||||
is_public_channel_name,
|
||||
)
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_event, broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
|
||||
|
||||
def _broadcast_channel_update(channel: Channel) -> None:
|
||||
broadcast_event("channel", channel.model_dump())
|
||||
|
||||
|
||||
class CreateChannelRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=32)
|
||||
key: str | None = Field(
|
||||
@@ -23,19 +34,189 @@ class CreateChannelRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsRequest(BaseModel):
|
||||
channel_names: list[str] = Field(
|
||||
min_length=1,
|
||||
description="List of hashtag room names. Leading # is optional per entry.",
|
||||
)
|
||||
try_historical: bool = Field(
|
||||
default=False,
|
||||
description="Attempt one background historical decrypt sweep for the newly added rooms.",
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsResponse(BaseModel):
|
||||
created_channels: list[Channel]
|
||||
existing_count: int
|
||||
invalid_names: list[str]
|
||||
decrypt_started: bool = False
|
||||
decrypt_total_packets: int = 0
|
||||
message: str
|
||||
|
||||
|
||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
flood_scope_override: str = Field(
|
||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||
)
|
||||
|
||||
|
||||
class ChannelPathHashModeOverrideRequest(BaseModel):
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=2,
|
||||
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
|
||||
|
||||
def _derive_channel_identity(
|
||||
requested_name: str,
|
||||
request_key: str | None = None,
|
||||
) -> tuple[str, str, bool]:
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
if is_public_channel_name(requested_name):
|
||||
if request_key:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||
)
|
||||
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
|
||||
|
||||
if request_key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
key_hex = key_bytes.hex().upper()
|
||||
if is_public_channel_key(key_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||
)
|
||||
return key_hex, requested_name, False
|
||||
|
||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||
return key_bytes.hex().upper(), requested_name, is_hashtag
|
||||
|
||||
|
||||
def _normalize_bulk_hashtag_name(name: str) -> str | None:
|
||||
trimmed = name.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
normalized = trimmed.lstrip("#").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) > 31:
|
||||
return None
|
||||
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
|
||||
return None
|
||||
return f"#{normalized}"
|
||||
|
||||
|
||||
async def _run_historical_channel_decryption_for_channels(
|
||||
channels: list[tuple[bytes, str, str]],
|
||||
) -> None:
|
||||
total = await RawPacketRepository.get_undecrypted_count()
|
||||
decrypted_count = 0
|
||||
matched_channel_names: set[str] = set()
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted packets to process for bulk channel decrypt")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Starting bulk historical channel decryption of %d packets across %d channels",
|
||||
total,
|
||||
len(channels),
|
||||
)
|
||||
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_all_undecrypted():
|
||||
packet_info = parse_packet(packet_data)
|
||||
path_hex = packet_info.path.hex() if packet_info else None
|
||||
path_len = packet_info.path_length if packet_info else None
|
||||
|
||||
for channel_key_bytes, channel_key_hex, channel_name in channels:
|
||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key_hex,
|
||||
channel_name=channel_name,
|
||||
sender=result.sender,
|
||||
message_text=result.message,
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=path_len,
|
||||
realtime=False,
|
||||
)
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
matched_channel_names.add(channel_name)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
|
||||
decrypted_count,
|
||||
total,
|
||||
len(matched_channel_names),
|
||||
)
|
||||
|
||||
if decrypted_count > 0:
|
||||
broadcast_success(
|
||||
"Bulk historical decrypt complete",
|
||||
(
|
||||
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
|
||||
f"across {len(matched_channel_names)} room"
|
||||
f"{'s' if len(matched_channel_names) != 1 else ''}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
return await ChannelRepository.get_all()
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=Channel)
|
||||
async def get_channel(key: str) -> Channel:
|
||||
"""Get a specific channel by key (32-char hex string)."""
|
||||
@router.get("/{key}/detail", response_model=ChannelDetail)
|
||||
async def get_channel_detail(key: str) -> ChannelDetail:
|
||||
"""Get comprehensive channel profile data with message statistics."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return channel
|
||||
|
||||
stats = await MessageRepository.get_channel_stats(channel.key)
|
||||
|
||||
return ChannelDetail(
|
||||
channel=channel,
|
||||
message_counts=ChannelMessageCounts(**stats["message_counts"]),
|
||||
first_message_at=stats["first_message_at"],
|
||||
unique_sender_count=stats["unique_sender_count"],
|
||||
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
||||
path_hash_width_24h=stats["path_hash_width_24h"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=Channel)
|
||||
@@ -45,63 +226,100 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
Channels are NOT pushed to radio on creation. They are loaded to the radio
|
||||
automatically when sending a message (see messages.py send_channel_message).
|
||||
"""
|
||||
is_hashtag = request.name.startswith("#")
|
||||
requested_name = request.name
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
||||
|
||||
# Determine the channel secret
|
||||
if request.key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
else:
|
||||
# Derive key from name hash (same as meshcore library does)
|
||||
key_bytes = sha256(request.name.encode("utf-8")).digest()[:16]
|
||||
|
||||
key_hex = key_bytes.hex().upper()
|
||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, request.name, is_hashtag)
|
||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
||||
|
||||
# Store in database only - radio sync happens at send time
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=request.name,
|
||||
name=channel_name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
|
||||
return Channel(
|
||||
key=key_hex,
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
|
||||
|
||||
_broadcast_channel_update(stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
|
||||
async def bulk_create_hashtag_channels(
|
||||
request: BulkCreateHashtagChannelsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
) -> BulkCreateHashtagChannelsResponse:
|
||||
created_channels: list[Channel] = []
|
||||
existing_count = 0
|
||||
invalid_names: list[str] = []
|
||||
decrypt_started = False
|
||||
decrypt_total_packets = 0
|
||||
decrypt_targets: list[tuple[bytes, str, str]] = []
|
||||
|
||||
for raw_name in request.channel_names:
|
||||
normalized_name = _normalize_bulk_hashtag_name(raw_name)
|
||||
if normalized_name is None:
|
||||
invalid_names.append(raw_name)
|
||||
continue
|
||||
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
|
||||
existing = await ChannelRepository.get_by_key(key_hex)
|
||||
if existing is not None:
|
||||
existing_count += 1
|
||||
continue
|
||||
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=channel_name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Channel was created but could not be reloaded",
|
||||
)
|
||||
|
||||
created_channels.append(stored)
|
||||
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
|
||||
_broadcast_channel_update(stored)
|
||||
|
||||
if request.try_historical and decrypt_targets:
|
||||
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
|
||||
if decrypt_total_packets > 0:
|
||||
background_tasks.add_task(
|
||||
_run_historical_channel_decryption_for_channels, decrypt_targets
|
||||
)
|
||||
decrypt_started = True
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
|
||||
message = (
|
||||
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
|
||||
if created_channels
|
||||
else "No new rooms were added"
|
||||
)
|
||||
if request.try_historical and decrypt_targets:
|
||||
if decrypt_started:
|
||||
message += (
|
||||
f" and started background decrypt of {decrypt_total_packets} packet"
|
||||
f"{'s' if decrypt_total_packets != 1 else ''}"
|
||||
)
|
||||
else:
|
||||
message += "; no undecrypted packets were available"
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, le=40)) -> dict:
|
||||
"""Sync channels from the radio to the database."""
|
||||
require_connected()
|
||||
|
||||
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
|
||||
count = 0
|
||||
|
||||
async with radio_manager.radio_operation("sync_channels_from_radio") as mc:
|
||||
for idx in range(max_channels):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type == EventType.CHANNEL_INFO:
|
||||
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
|
||||
if key_hex is not None:
|
||||
count += 1
|
||||
logger.debug(
|
||||
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
|
||||
)
|
||||
|
||||
logger.info("Synced %d channels from radio", count)
|
||||
return {"synced": count}
|
||||
return BulkCreateHashtagChannelsResponse(
|
||||
created_channels=created_channels,
|
||||
existing_count=existing_count,
|
||||
invalid_names=invalid_names,
|
||||
decrypt_started=decrypt_started,
|
||||
decrypt_total_packets=decrypt_total_packets,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{key}/mark-read")
|
||||
@@ -118,6 +336,51 @@ async def mark_channel_read(key: str) -> dict:
|
||||
return {"status": "ok", "key": channel.key}
|
||||
|
||||
|
||||
@router.post("/{key}/flood-scope-override", response_model=Channel)
|
||||
async def set_channel_flood_scope_override(
|
||||
key: str, request: ChannelFloodScopeOverrideRequest
|
||||
) -> Channel:
|
||||
"""Set or clear a per-channel flood-scope override."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
override = normalize_region_scope(request.flood_scope_override) or None
|
||||
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
|
||||
async def set_channel_path_hash_mode_override(
|
||||
key: str, request: ChannelPathHashModeOverrideRequest
|
||||
) -> Channel:
|
||||
"""Set or clear a per-channel path hash mode override."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
updated = await ChannelRepository.update_path_hash_mode_override(
|
||||
channel.key, request.path_hash_mode_override
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
@@ -125,6 +388,14 @@ async def delete_channel(key: str) -> dict:
|
||||
Note: This does not clear the channel from the radio. The radio's channel
|
||||
slots are managed separately (channels are loaded temporarily when sending).
|
||||
"""
|
||||
if is_public_channel_key(key):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="The canonical Public channel cannot be deleted"
|
||||
)
|
||||
|
||||
logger.info("Deleting channel %s from database", key)
|
||||
await ChannelRepository.delete(key)
|
||||
|
||||
broadcast_event("channel_deleted", {"key": key})
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
+396
-241
@@ -1,22 +1,28 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
ContactAnalytics,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
NearestRepeater,
|
||||
PathDiscoveryResponse,
|
||||
PathDiscoveryRoute,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
from app.radio import radio_manager
|
||||
from app.path_utils import parse_explicit_hop_route
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactAdvertPathRepository,
|
||||
@@ -24,12 +30,21 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
TRACE_HASH_BYTES = 4
|
||||
TRACE_FLAGS_4BYTE = 2
|
||||
|
||||
|
||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||
return (
|
||||
@@ -59,6 +74,149 @@ async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
|
||||
"""Best-effort push the current effective route to the radio when connected."""
|
||||
if not radio_manager.is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(operation_name) as mc:
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s: %s",
|
||||
contact.public_key[:12],
|
||||
result.payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s",
|
||||
contact.public_key[:12],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_contact_update(contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
|
||||
async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
for old_key in previous_public_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _path_hash_mode_from_hop_width(hop_width: object) -> int:
|
||||
if not isinstance(hop_width, int):
|
||||
return 0
|
||||
return max(0, min(hop_width - 1, 2))
|
||||
|
||||
|
||||
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
|
||||
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
|
||||
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
hourly_activity, weekly_activity = await MessageRepository.get_contact_activity_series(
|
||||
contact.public_key
|
||||
)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
|
||||
for key, name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
advert_frequency: float | None = None
|
||||
if advert_paths:
|
||||
total_observations = sum(p.heard_count for p in advert_paths)
|
||||
earliest = min(p.first_seen for p in advert_paths)
|
||||
latest = max(p.last_seen for p in advert_paths)
|
||||
span_hours = (latest - earliest) / 3600.0
|
||||
if span_hours > 0:
|
||||
advert_frequency = round(total_observations / span_hours, 2)
|
||||
|
||||
first_hop_stats: dict[str, dict] = {}
|
||||
for p in advert_paths:
|
||||
prefix = p.next_hop
|
||||
if prefix:
|
||||
if prefix not in first_hop_stats:
|
||||
first_hop_stats[prefix] = {
|
||||
"heard_count": 0,
|
||||
"path_len": p.path_len,
|
||||
"last_seen": p.last_seen,
|
||||
}
|
||||
first_hop_stats[prefix]["heard_count"] += p.heard_count
|
||||
first_hop_stats[prefix]["last_seen"] = max(
|
||||
first_hop_stats[prefix]["last_seen"], p.last_seen
|
||||
)
|
||||
|
||||
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
|
||||
|
||||
nearest_repeaters: list[NearestRepeater] = []
|
||||
for prefix, stats in first_hop_stats.items():
|
||||
resolved = resolved_contacts.get(prefix)
|
||||
nearest_repeaters.append(
|
||||
NearestRepeater(
|
||||
public_key=resolved.public_key if resolved else prefix,
|
||||
name=resolved.name if resolved else None,
|
||||
path_len=stats["path_len"],
|
||||
last_seen=stats["last_seen"],
|
||||
heard_count=stats["heard_count"],
|
||||
)
|
||||
)
|
||||
|
||||
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
|
||||
|
||||
return ContactAnalytics(
|
||||
lookup_type="contact",
|
||||
name=contact.name or contact.public_key[:12],
|
||||
contact=contact,
|
||||
name_history=name_history,
|
||||
dm_message_count=dm_count,
|
||||
channel_message_count=chan_count,
|
||||
includes_direct_messages=True,
|
||||
most_active_rooms=most_active_rooms,
|
||||
advert_paths=advert_paths,
|
||||
advert_frequency=advert_frequency,
|
||||
nearest_repeaters=nearest_repeaters,
|
||||
hourly_activity=hourly_activity,
|
||||
weekly_activity=weekly_activity,
|
||||
)
|
||||
|
||||
|
||||
async def _build_name_only_contact_analytics(name: str) -> ContactAnalytics:
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender_name(name)
|
||||
name_first_seen_at = await MessageRepository.get_first_channel_message_by_sender_name(name)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(name)
|
||||
hourly_activity, weekly_activity = await MessageRepository.get_sender_name_activity_series(name)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
|
||||
for key, room_name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
return ContactAnalytics(
|
||||
lookup_type="name",
|
||||
name=name,
|
||||
name_first_seen_at=name_first_seen_at,
|
||||
channel_message_count=chan_count,
|
||||
includes_direct_messages=False,
|
||||
most_active_rooms=most_active_rooms,
|
||||
hourly_activity=hourly_activity,
|
||||
weekly_activity=weekly_activity,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
async def list_contacts(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -82,6 +240,26 @@ async def list_repeater_advert_paths(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics", response_model=ContactAnalytics)
|
||||
async def get_contact_analytics(
|
||||
public_key: str | None = Query(default=None),
|
||||
name: str | None = Query(default=None, min_length=1, max_length=200),
|
||||
) -> ContactAnalytics:
|
||||
"""Get unified contact analytics for either a keyed contact or a sender name."""
|
||||
if bool(public_key) == bool(name):
|
||||
raise HTTPException(status_code=400, detail="Specify exactly one of public_key or name")
|
||||
|
||||
if public_key:
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
return await _build_keyed_contact_analytics(contact)
|
||||
|
||||
assert name is not None
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
return await _build_name_only_contact_analytics(normalized_name)
|
||||
|
||||
|
||||
@router.post("", response_model=Contact)
|
||||
async def create_contact(
|
||||
request: CreateContactRequest, background_tasks: BackgroundTasks
|
||||
@@ -100,27 +278,28 @@ async def create_contact(
|
||||
# Check if contact already exists
|
||||
existing = await ContactRepository.get_by_key(request.public_key)
|
||||
if existing:
|
||||
# Update name if provided
|
||||
# Update name if provided and record name history
|
||||
if request.name:
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": existing.public_key,
|
||||
"name": request.name,
|
||||
"type": existing.type,
|
||||
"flags": existing.flags,
|
||||
"last_path": existing.last_path,
|
||||
"last_path_len": existing.last_path_len,
|
||||
"last_advert": existing.last_advert,
|
||||
"lat": existing.lat,
|
||||
"lon": existing.lon,
|
||||
"last_seen": existing.last_seen,
|
||||
"on_radio": existing.on_radio,
|
||||
"last_contacted": existing.last_contacted,
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=request.public_key,
|
||||
contact_name=request.name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=request.public_key,
|
||||
log=logger,
|
||||
)
|
||||
if promoted_keys:
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
await _broadcast_contact_resolution(promoted_keys, existing)
|
||||
|
||||
# Trigger historical decryption if requested (even for existing contacts)
|
||||
if request.try_historical:
|
||||
@@ -128,213 +307,40 @@ async def create_contact(
|
||||
background_tasks, request.public_key, request.name or existing.name
|
||||
)
|
||||
|
||||
await _broadcast_contact_update(existing)
|
||||
return existing
|
||||
|
||||
# Create new contact
|
||||
lower_key = request.public_key.lower()
|
||||
contact_data = {
|
||||
"public_key": lower_key,
|
||||
"name": request.name,
|
||||
"type": 0, # Unknown
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=lower_key,
|
||||
name=request.name,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
logger.info("Created contact %s", lower_key[:12])
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=lower_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Promote any prefix-stored messages to this full key
|
||||
claimed = await MessageRepository.claim_prefix_messages(lower_key)
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12])
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=lower_key,
|
||||
contact_name=request.name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Trigger historical decryption if requested
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
|
||||
|
||||
return Contact(**contact_data)
|
||||
|
||||
|
||||
@router.get("/{public_key}/detail", response_model=ContactDetail)
|
||||
async def get_contact_detail(public_key: str) -> ContactDetail:
|
||||
"""Get comprehensive contact profile data.
|
||||
|
||||
Returns contact info, name history, message counts, most active rooms,
|
||||
advertisement paths, advert frequency, and nearest repeaters.
|
||||
"""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
|
||||
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
|
||||
for key, name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
# Compute advert observation rate (observations/hour) from path data.
|
||||
# Note: a single advertisement can arrive via multiple paths, so this counts
|
||||
# RF observations, not unique advertisement broadcasts.
|
||||
advert_frequency: float | None = None
|
||||
if advert_paths:
|
||||
total_observations = sum(p.heard_count for p in advert_paths)
|
||||
earliest = min(p.first_seen for p in advert_paths)
|
||||
latest = max(p.last_seen for p in advert_paths)
|
||||
span_hours = (latest - earliest) / 3600.0
|
||||
if span_hours > 0:
|
||||
advert_frequency = round(total_observations / span_hours, 2)
|
||||
|
||||
# Compute nearest repeaters from first-hop prefixes in advert paths
|
||||
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
|
||||
for p in advert_paths:
|
||||
if p.path and len(p.path) >= 2:
|
||||
prefix = p.path[:2].lower()
|
||||
if prefix not in first_hop_stats:
|
||||
first_hop_stats[prefix] = {
|
||||
"heard_count": 0,
|
||||
"path_len": p.path_len,
|
||||
"last_seen": p.last_seen,
|
||||
}
|
||||
first_hop_stats[prefix]["heard_count"] += p.heard_count
|
||||
first_hop_stats[prefix]["last_seen"] = max(
|
||||
first_hop_stats[prefix]["last_seen"], p.last_seen
|
||||
)
|
||||
|
||||
# Resolve all first-hop prefixes to contacts in a single query
|
||||
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
|
||||
|
||||
nearest_repeaters: list[NearestRepeater] = []
|
||||
for prefix, stats in first_hop_stats.items():
|
||||
resolved = resolved_contacts.get(prefix)
|
||||
nearest_repeaters.append(
|
||||
NearestRepeater(
|
||||
public_key=resolved.public_key if resolved else prefix,
|
||||
name=resolved.name if resolved else None,
|
||||
path_len=stats["path_len"],
|
||||
last_seen=stats["last_seen"],
|
||||
heard_count=stats["heard_count"],
|
||||
)
|
||||
)
|
||||
|
||||
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
|
||||
|
||||
return ContactDetail(
|
||||
contact=contact,
|
||||
name_history=name_history,
|
||||
dm_message_count=dm_count,
|
||||
channel_message_count=chan_count,
|
||||
most_active_rooms=most_active_rooms,
|
||||
advert_paths=advert_paths,
|
||||
advert_frequency=advert_frequency,
|
||||
nearest_repeaters=nearest_repeaters,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=Contact)
|
||||
async def get_contact(public_key: str) -> Contact:
|
||||
"""Get a specific contact by public key or prefix."""
|
||||
return await _resolve_contact_or_404(public_key)
|
||||
|
||||
|
||||
@router.get("/{public_key}/advert-paths", response_model=list[ContactAdvertPath])
|
||||
async def get_contact_advert_paths(
|
||||
public_key: str,
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
) -> list[ContactAdvertPath]:
|
||||
"""List recent unique advert paths for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
return await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key, limit)
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_contacts_from_radio() -> dict:
|
||||
"""Sync contacts from the radio to the database."""
|
||||
require_connected()
|
||||
|
||||
logger.info("Syncing contacts from radio")
|
||||
|
||||
async with radio_manager.radio_operation("sync_contacts_from_radio") as mc:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get contacts: {result.payload}")
|
||||
|
||||
contacts = result.payload
|
||||
count = 0
|
||||
|
||||
for public_key, contact_data in contacts.items():
|
||||
lower_key = public_key.lower()
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
)
|
||||
claimed = await MessageRepository.claim_prefix_messages(lower_key)
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12])
|
||||
count += 1
|
||||
|
||||
logger.info("Synced %d contacts from radio", count)
|
||||
return {"synced": count}
|
||||
|
||||
|
||||
@router.post("/{public_key}/remove-from-radio")
|
||||
async def remove_contact_from_radio(public_key: str) -> dict:
|
||||
"""Remove a contact from the radio (keeps it in database)."""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
async with radio_manager.radio_operation("remove_contact_from_radio") as mc:
|
||||
# Get the contact from radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not radio_contact:
|
||||
# Already not on radio
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok", "message": "Contact was not on radio"}
|
||||
|
||||
logger.info("Removing contact %s from radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.remove_contact(radio_contact)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to remove contact: {result.payload}"
|
||||
)
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{public_key}/add-to-radio")
|
||||
async def add_contact_to_radio(public_key: str) -> dict:
|
||||
"""Add a contact from the database to the radio."""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key, "Contact not found in database")
|
||||
|
||||
async with radio_manager.radio_operation("add_contact_to_radio") as mc:
|
||||
# Check if already on radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
return {"status": "ok", "message": "Contact already on radio"}
|
||||
|
||||
logger.info("Adding contact %s to radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add contact: {result.payload}")
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
return {"status": "ok"}
|
||||
stored = await ContactRepository.get_by_key(lower_key)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded")
|
||||
await _broadcast_contact_update(stored)
|
||||
await _broadcast_contact_resolution(promoted_keys, stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/{public_key}/mark-read")
|
||||
@@ -349,6 +355,44 @@ async def mark_contact_read(public_key: str) -> dict:
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
public_keys: list[str] = Field(description="Public keys to delete")
|
||||
|
||||
|
||||
@router.post("/bulk-delete")
|
||||
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
|
||||
"""Delete multiple contacts from the database (and radio if present)."""
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
# Resolve all contacts first
|
||||
contacts_to_delete: list[Contact] = []
|
||||
for key in request.public_keys:
|
||||
contact = await ContactRepository.get_by_key(key.lower())
|
||||
if contact:
|
||||
contacts_to_delete.append(contact)
|
||||
|
||||
# Remove from radio in a single locked operation (blocks until radio is free)
|
||||
if radio_manager.is_connected and contacts_to_delete:
|
||||
try:
|
||||
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
|
||||
for contact in contacts_to_delete:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
await mc.commands.remove_contact(radio_contact)
|
||||
except Exception as e:
|
||||
logger.warning("Radio removal during bulk delete failed: %s", e)
|
||||
|
||||
# Delete from database and broadcast events
|
||||
deleted = 0
|
||||
for contact in contacts_to_delete:
|
||||
await ContactRepository.delete(contact.public_key)
|
||||
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||
deleted += 1
|
||||
|
||||
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.delete("/{public_key}")
|
||||
async def delete_contact(public_key: str) -> dict:
|
||||
"""Delete a contact from the database (and radio if present)."""
|
||||
@@ -368,6 +412,10 @@ async def delete_contact(public_key: str) -> dict:
|
||||
await ContactRepository.delete(contact.public_key)
|
||||
logger.info("Deleted contact %s", contact.public_key[:12])
|
||||
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -375,17 +423,17 @@ async def delete_contact(public_key: str) -> dict:
|
||||
async def request_trace(public_key: str) -> TraceResponse:
|
||||
"""Send a single-hop trace to a contact and wait for the result.
|
||||
|
||||
The trace path contains the contact's 1-byte pubkey hash as the sole hop
|
||||
(no intermediate repeaters). The radio firmware requires at least one
|
||||
node in the path.
|
||||
The trace path contains the contact's 4-byte pubkey hash as the sole hop
|
||||
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||
than the radio's normal path_hash_mode setting.
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
|
||||
contact_hash = contact.public_key[:2]
|
||||
# Use a 4-byte contact hash for low-collision direct trace targeting.
|
||||
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
|
||||
|
||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||
# from the reader loop, not via get_msg().
|
||||
@@ -396,7 +444,11 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
logger.info(
|
||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||
)
|
||||
result = await mc.commands.send_trace(path=contact_hash, tag=tag)
|
||||
result = await mc.commands.send_trace(
|
||||
path=contact_hash,
|
||||
tag=tag,
|
||||
flags=TRACE_FLAGS_4BYTE,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
||||
@@ -431,29 +483,132 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len)
|
||||
|
||||
|
||||
@router.post("/{public_key}/reset-path")
|
||||
async def reset_contact_path(public_key: str) -> dict:
|
||||
"""Reset a contact's routing path to flood."""
|
||||
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
|
||||
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
"""Discover the current forward and return paths to a known contact."""
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
pubkey_prefix = contact.public_key[:12]
|
||||
|
||||
async with radio_manager.radio_operation("request_path_discovery", pause_polling=True) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
response_task = asyncio.create_task(
|
||||
mc.wait_for_event(
|
||||
EventType.PATH_RESPONSE,
|
||||
attribute_filters={"pubkey_pre": pubkey_prefix},
|
||||
timeout=15,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await mc.commands.send_path_discovery(contact.public_key)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send path discovery: {result.payload}",
|
||||
)
|
||||
|
||||
event = await response_task
|
||||
finally:
|
||||
if not response_task.done():
|
||||
response_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await response_task
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No path discovery response heard")
|
||||
|
||||
payload = event.payload
|
||||
forward_path = str(payload.get("out_path") or "")
|
||||
forward_len = int(payload.get("out_path_len") or 0)
|
||||
forward_mode = _path_hash_mode_from_hop_width(payload.get("out_path_hash_len"))
|
||||
return_path = str(payload.get("in_path") or "")
|
||||
return_len = int(payload.get("in_path_len") or 0)
|
||||
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
|
||||
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
forward_path,
|
||||
forward_len,
|
||||
forward_mode,
|
||||
)
|
||||
refreshed_contact = await _resolve_contact_or_404(contact.public_key)
|
||||
|
||||
try:
|
||||
sync_result = await mc.commands.add_contact(refreshed_contact.to_radio_dict())
|
||||
if sync_result is not None and sync_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to sync discovered path back to radio for %s: %s",
|
||||
refreshed_contact.public_key[:12],
|
||||
sync_result.payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to sync discovered path back to radio for %s",
|
||||
refreshed_contact.public_key[:12],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
await _broadcast_contact_update(refreshed_contact)
|
||||
|
||||
return PathDiscoveryResponse(
|
||||
contact=refreshed_contact,
|
||||
forward_path=PathDiscoveryRoute(
|
||||
path=forward_path,
|
||||
path_len=forward_len,
|
||||
path_hash_mode=forward_mode,
|
||||
),
|
||||
return_path=PathDiscoveryRoute(
|
||||
path=return_path,
|
||||
path_len=return_len,
|
||||
path_hash_mode=return_mode,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/routing-override")
|
||||
async def set_contact_routing_override(
|
||||
public_key: str, request: ContactRoutingOverrideRequest
|
||||
) -> dict:
|
||||
"""Set, force, or clear an explicit routing override for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, "", -1)
|
||||
logger.info("Reset path to flood for %s", contact.public_key[:12])
|
||||
|
||||
# Push the updated path to radio if connected and contact is on radio
|
||||
if radio_manager.is_connected and contact.on_radio:
|
||||
route_text = request.route.strip()
|
||||
if route_text == "":
|
||||
await ContactRepository.clear_routing_override(contact.public_key)
|
||||
logger.info(
|
||||
"Cleared routing override for %s",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif route_text == "-1":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", -1, -1)
|
||||
logger.info("Set forced flood routing override for %s", contact.public_key[:12])
|
||||
elif route_text == "0":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", 0, 0)
|
||||
logger.info("Set forced direct routing override for %s", contact.public_key[:12])
|
||||
else:
|
||||
try:
|
||||
updated = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated:
|
||||
async with radio_manager.radio_operation("reset_path_on_radio") as mc:
|
||||
await mc.commands.add_contact(updated.to_radio_dict())
|
||||
except Exception:
|
||||
logger.warning("Failed to push flood path to radio for %s", contact.public_key[:12])
|
||||
path_hex, path_len, hash_mode = parse_explicit_hop_route(route_text)
|
||||
except ValueError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
|
||||
# Broadcast updated contact so frontend refreshes
|
||||
from app.websocket import broadcast_event
|
||||
await ContactRepository.set_routing_override(
|
||||
contact.public_key,
|
||||
path_hex,
|
||||
path_len,
|
||||
hash_mode,
|
||||
)
|
||||
logger.info(
|
||||
"Set explicit routing override for %s: %d hop(s), %d-byte IDs",
|
||||
contact.public_key[:12],
|
||||
path_len,
|
||||
hash_mode + 1,
|
||||
)
|
||||
|
||||
updated_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated_contact:
|
||||
broadcast_event("contact", updated_contact.model_dump())
|
||||
await _best_effort_push_contact_to_radio(updated_contact, "set_routing_override_on_radio")
|
||||
await _broadcast_contact_update(updated_contact)
|
||||
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import get_recent_log_lines, settings
|
||||
from app.models import AppSettings
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
|
||||
from app.routers.health import FanoutStatusResponse, build_health_data
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
from app.version_info import get_app_build_info, git_output
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["debug"])
|
||||
|
||||
LOG_COPY_BOUNDARY_MESSAGE = "STOP COPYING HERE IF YOU DO NOT WANT TO INCLUDE LOGS BELOW"
|
||||
LOG_COPY_BOUNDARY_LINE = "-" * 64
|
||||
LOG_COPY_BOUNDARY_PREFIX = [
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
LOG_COPY_BOUNDARY_MESSAGE,
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
LOG_COPY_BOUNDARY_LINE,
|
||||
]
|
||||
|
||||
|
||||
class DebugSystemInfo(BaseModel):
|
||||
os: str
|
||||
arch: str
|
||||
arch_bits: int
|
||||
total_ram_mb: int
|
||||
|
||||
|
||||
class DebugApplicationInfo(BaseModel):
|
||||
version: str
|
||||
version_source: str
|
||||
commit_hash: str | None = None
|
||||
commit_source: str | None = None
|
||||
git_branch: str | None = None
|
||||
git_dirty: bool | None = None
|
||||
python_version: str
|
||||
|
||||
|
||||
class DebugRuntimeInfo(BaseModel):
|
||||
connection_info: str | None = None
|
||||
connection_desired: bool
|
||||
setup_in_progress: bool
|
||||
setup_complete: bool
|
||||
channels_with_incoming_messages: int
|
||||
path_hash_mode_supported: bool
|
||||
channel_slot_reuse_enabled: bool
|
||||
channel_send_cache_capacity: int
|
||||
remediation_flags: dict[str, bool]
|
||||
|
||||
|
||||
class DebugContactAudit(BaseModel):
|
||||
expected_and_found: int
|
||||
expected_but_not_found: list[str]
|
||||
found_but_not_expected: list[str]
|
||||
|
||||
|
||||
class DebugChannelSlotMismatch(BaseModel):
|
||||
slot_number: int
|
||||
expected_sha256_of_room_key: str | None = None
|
||||
actual_sha256_of_room_key: str | None = None
|
||||
|
||||
|
||||
class DebugChannelAudit(BaseModel):
|
||||
matched_slots: int
|
||||
wrong_slots: list[DebugChannelSlotMismatch]
|
||||
|
||||
|
||||
class DebugRadioProbe(BaseModel):
|
||||
performed: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
self_info: dict[str, Any] | None = None
|
||||
device_info: dict[str, Any] | None = None
|
||||
stats_core: dict[str, Any] | None = None
|
||||
stats_radio: dict[str, Any] | None = None
|
||||
contacts: DebugContactAudit | None = None
|
||||
channels: DebugChannelAudit | None = None
|
||||
|
||||
|
||||
class DebugDatabaseInfo(BaseModel):
|
||||
total_dms: int
|
||||
total_channel_messages: int
|
||||
total_outgoing: int
|
||||
|
||||
|
||||
class DebugHealthSummary(BaseModel):
|
||||
radio_state: str
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
class DebugAppSettings(BaseModel):
|
||||
max_radio_contacts: int
|
||||
auto_decrypt_dm_on_advert: bool
|
||||
advert_interval: int
|
||||
flood_scope: str
|
||||
blocked_keys_count: int
|
||||
blocked_names_count: int
|
||||
|
||||
|
||||
class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
system: DebugSystemInfo
|
||||
application: DebugApplicationInfo
|
||||
health: DebugHealthSummary
|
||||
settings: DebugAppSettings
|
||||
runtime: DebugRuntimeInfo
|
||||
database: DebugDatabaseInfo
|
||||
radio_probe: DebugRadioProbe
|
||||
logs: list[str]
|
||||
|
||||
|
||||
def _build_system_info() -> DebugSystemInfo:
|
||||
try:
|
||||
# os.sysconf is available on Linux/macOS
|
||||
page_size = os.sysconf("SC_PAGE_SIZE")
|
||||
page_count = os.sysconf("SC_PHYS_PAGES")
|
||||
total_ram_mb = (page_size * page_count) // (1024 * 1024)
|
||||
except (AttributeError, ValueError, OSError):
|
||||
total_ram_mb = 0
|
||||
|
||||
return DebugSystemInfo(
|
||||
os=f"{platform.system()} {platform.release()}",
|
||||
arch=platform.machine(),
|
||||
arch_bits=struct.calcsize("P") * 8,
|
||||
total_ram_mb=total_ram_mb,
|
||||
)
|
||||
|
||||
|
||||
def _build_application_info() -> DebugApplicationInfo:
|
||||
build_info = get_app_build_info()
|
||||
dirty_output = git_output("status", "--porcelain")
|
||||
return DebugApplicationInfo(
|
||||
version=build_info.version,
|
||||
version_source=build_info.version_source,
|
||||
commit_hash=build_info.commit_hash,
|
||||
commit_source=build_info.commit_source,
|
||||
git_branch=git_output("rev-parse", "--abbrev-ref", "HEAD"),
|
||||
git_dirty=(dirty_output is not None and dirty_output != ""),
|
||||
python_version=sys.version.split()[0],
|
||||
)
|
||||
|
||||
|
||||
def _event_type_name(event: Any) -> str:
|
||||
event_type = getattr(event, "type", None)
|
||||
return getattr(event_type, "name", str(event_type))
|
||||
|
||||
|
||||
def _sha256_hex(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_channel_secret(payload: dict[str, Any]) -> bytes:
|
||||
secret = payload.get("channel_secret", b"")
|
||||
if isinstance(secret, bytes):
|
||||
return secret
|
||||
return bytes(secret)
|
||||
|
||||
|
||||
def _is_empty_channel_payload(payload: dict[str, Any]) -> bool:
|
||||
name = payload.get("channel_name", "")
|
||||
return not name or name == "\x00" * len(name)
|
||||
|
||||
|
||||
def _observed_channel_key(event: Any) -> str | None:
|
||||
if getattr(event, "type", None) != EventType.CHANNEL_INFO:
|
||||
return None
|
||||
|
||||
payload = event.payload or {}
|
||||
if _is_empty_channel_payload(payload):
|
||||
return None
|
||||
|
||||
return _normalize_channel_secret(payload).hex().upper()
|
||||
|
||||
|
||||
def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
||||
if not device_info or "max_channels" not in device_info:
|
||||
return None
|
||||
try:
|
||||
return int(device_info["max_channels"])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||
return DebugAppSettings(
|
||||
max_radio_contacts=app_settings.max_radio_contacts,
|
||||
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
|
||||
advert_interval=app_settings.advert_interval,
|
||||
flood_scope=app_settings.flood_scope,
|
||||
blocked_keys_count=len(app_settings.blocked_keys),
|
||||
blocked_names_count=len(app_settings.blocked_names),
|
||||
)
|
||||
|
||||
|
||||
def _derive_debug_radio_state(
|
||||
*,
|
||||
radio_connected: bool,
|
||||
connection_desired: bool,
|
||||
setup_in_progress: bool,
|
||||
setup_complete: bool,
|
||||
is_reconnecting: bool,
|
||||
) -> str:
|
||||
if not connection_desired:
|
||||
return "paused"
|
||||
if radio_connected and (setup_in_progress or not setup_complete):
|
||||
return "initializing"
|
||||
if radio_connected:
|
||||
return "connected"
|
||||
if is_reconnecting:
|
||||
return "connecting"
|
||||
return "disconnected"
|
||||
|
||||
|
||||
def _build_debug_health_summary(
|
||||
health_data: dict[str, Any], *, radio_state: str
|
||||
) -> DebugHealthSummary:
|
||||
def _fanout_last_error(status: Any) -> str | None:
|
||||
if isinstance(status, dict):
|
||||
value = status.get("last_error")
|
||||
else:
|
||||
value = getattr(status, "last_error", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
fanouts_with_errors = {
|
||||
config_id: status
|
||||
for config_id, status in health_data["fanout_statuses"].items()
|
||||
if _fanout_last_error(status)
|
||||
}
|
||||
return DebugHealthSummary(
|
||||
radio_state=radio_state,
|
||||
database_size_mb=health_data["database_size_mb"],
|
||||
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
|
||||
fanouts_with_errors=fanouts_with_errors,
|
||||
bots_disabled_source=health_data["bots_disabled_source"],
|
||||
basic_auth_enabled=health_data["basic_auth_enabled"],
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
|
||||
sanitized = dict(self_info or {})
|
||||
sanitized.pop("adv_lat", None)
|
||||
sanitized.pop("adv_lon", None)
|
||||
return sanitized
|
||||
|
||||
|
||||
async def _build_contact_audit(
|
||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||
) -> DebugContactAudit:
|
||||
expected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
expected_keys = {contact.public_key.lower() for contact in expected_contacts}
|
||||
observed_keys = {public_key.lower() for public_key in observed_contacts_payload}
|
||||
|
||||
return DebugContactAudit(
|
||||
expected_and_found=len(expected_keys & observed_keys),
|
||||
expected_but_not_found=sorted(_sha256_hex(key) for key in (expected_keys - observed_keys)),
|
||||
found_but_not_expected=sorted(_sha256_hex(key) for key in (observed_keys - expected_keys)),
|
||||
)
|
||||
|
||||
|
||||
async def _build_channel_audit(mc: Any, max_channels: int | None = None) -> DebugChannelAudit:
|
||||
cache_key_by_slot = {
|
||||
slot: channel_key for channel_key, slot in radio_runtime.get_channel_send_cache_snapshot()
|
||||
}
|
||||
|
||||
matched_slots = 0
|
||||
wrong_slots: list[DebugChannelSlotMismatch] = []
|
||||
for slot in range(get_radio_channel_limit(max_channels)):
|
||||
event = await mc.commands.get_channel(slot)
|
||||
expected_key = cache_key_by_slot.get(slot)
|
||||
observed_key = _observed_channel_key(event)
|
||||
if expected_key == observed_key:
|
||||
matched_slots += 1
|
||||
continue
|
||||
wrong_slots.append(
|
||||
DebugChannelSlotMismatch(
|
||||
slot_number=slot,
|
||||
expected_sha256_of_room_key=_sha256_hex(expected_key) if expected_key else None,
|
||||
actual_sha256_of_room_key=_sha256_hex(observed_key) if observed_key else None,
|
||||
)
|
||||
)
|
||||
|
||||
return DebugChannelAudit(
|
||||
matched_slots=matched_slots,
|
||||
wrong_slots=wrong_slots,
|
||||
)
|
||||
|
||||
|
||||
async def _probe_radio() -> DebugRadioProbe:
|
||||
if not radio_runtime.is_connected:
|
||||
return DebugRadioProbe(performed=False, errors=["Radio not connected"])
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
async with radio_runtime.radio_operation(
|
||||
"debug_support_snapshot",
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
device_info = None
|
||||
stats_core = None
|
||||
stats_radio = None
|
||||
|
||||
device_event = await mc.commands.send_device_query()
|
||||
if getattr(device_event, "type", None) == EventType.DEVICE_INFO:
|
||||
device_info = device_event.payload
|
||||
else:
|
||||
errors.append(f"send_device_query returned {_event_type_name(device_event)}")
|
||||
|
||||
core_event = await mc.commands.get_stats_core()
|
||||
if getattr(core_event, "type", None) == EventType.STATS_CORE:
|
||||
stats_core = core_event.payload
|
||||
else:
|
||||
errors.append(f"get_stats_core returned {_event_type_name(core_event)}")
|
||||
|
||||
radio_event = await mc.commands.get_stats_radio()
|
||||
if getattr(radio_event, "type", None) == EventType.STATS_RADIO:
|
||||
stats_radio = radio_event.payload
|
||||
else:
|
||||
errors.append(f"get_stats_radio returned {_event_type_name(radio_event)}")
|
||||
|
||||
contacts_event = await mc.commands.get_contacts()
|
||||
observed_contacts_payload: dict[str, dict[str, Any]] = {}
|
||||
if getattr(contacts_event, "type", None) != EventType.ERROR:
|
||||
observed_contacts_payload = contacts_event.payload or {}
|
||||
else:
|
||||
errors.append(f"get_contacts returned {_event_type_name(contacts_event)}")
|
||||
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
stats_radio=stats_radio,
|
||||
contacts=await _build_contact_audit(observed_contacts_payload),
|
||||
channels=await _build_channel_audit(
|
||||
mc,
|
||||
max_channels=_coerce_live_max_channels(device_info),
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Debug support snapshot radio probe failed: %s", exc, exc_info=True)
|
||||
errors.append(str(exc))
|
||||
return DebugRadioProbe(performed=False, errors=errors)
|
||||
|
||||
|
||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||
connection_info = radio_runtime.connection_info
|
||||
connection_desired = radio_runtime.connection_desired
|
||||
setup_in_progress = radio_runtime.is_setup_in_progress
|
||||
setup_complete = radio_runtime.is_setup_complete
|
||||
radio_connected = radio_runtime.is_connected
|
||||
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
|
||||
|
||||
health_data = await build_health_data(radio_connected, connection_info)
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||
radio_probe = await _probe_radio()
|
||||
channels_with_incoming_messages = (
|
||||
await MessageRepository.count_channels_with_incoming_messages()
|
||||
)
|
||||
radio_state = _derive_debug_radio_state(
|
||||
radio_connected=radio_connected,
|
||||
connection_desired=connection_desired,
|
||||
setup_in_progress=setup_in_progress,
|
||||
setup_complete=setup_complete,
|
||||
is_reconnecting=is_reconnecting,
|
||||
)
|
||||
return DebugSnapshotResponse(
|
||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||
system=_build_system_info(),
|
||||
application=_build_application_info(),
|
||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||
settings=_build_debug_app_settings(app_settings),
|
||||
runtime=DebugRuntimeInfo(
|
||||
connection_info=connection_info,
|
||||
connection_desired=connection_desired,
|
||||
setup_in_progress=setup_in_progress,
|
||||
setup_complete=setup_complete,
|
||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||
remediation_flags={
|
||||
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
||||
},
|
||||
),
|
||||
database=DebugDatabaseInfo(
|
||||
total_dms=message_totals["total_dms"],
|
||||
total_channel_messages=message_totals["total_channel_messages"],
|
||||
total_outgoing=message_totals["total_outgoing"],
|
||||
),
|
||||
radio_probe=radio_probe,
|
||||
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
||||
)
|
||||
@@ -0,0 +1,463 @@
|
||||
"""REST API for fanout config CRUD."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.fanout.bot_exec import _analyze_bot_signature
|
||||
from app.fanout.manager import fanout_manager
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "map_upload"}
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
_DEFAULT_COMMUNITY_MQTT_BROKER_HOST = "mqtt-us-v1.letsmesh.net"
|
||||
_DEFAULT_COMMUNITY_MQTT_BROKER_PORT = 443
|
||||
_DEFAULT_COMMUNITY_MQTT_TRANSPORT = "websockets"
|
||||
_DEFAULT_COMMUNITY_MQTT_AUTH_MODE = "token"
|
||||
_COMMUNITY_MQTT_TEMPLATE_FIELD_CANONICAL = {
|
||||
"iata": "IATA",
|
||||
"public_key": "PUBLIC_KEY",
|
||||
}
|
||||
_ALLOWED_COMMUNITY_MQTT_TRANSPORTS = {"tcp", "websockets"}
|
||||
_ALLOWED_COMMUNITY_MQTT_AUTH_MODES = {"token", "password", "none"}
|
||||
|
||||
|
||||
def _normalize_community_topic_template(topic_template: str) -> str:
|
||||
"""Normalize Community MQTT topic template placeholders to canonical uppercase form."""
|
||||
template = topic_template.strip() or _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE
|
||||
parts: list[str] = []
|
||||
try:
|
||||
parsed = string.Formatter().parse(template)
|
||||
for literal_text, field_name, format_spec, conversion in parsed:
|
||||
parts.append(literal_text)
|
||||
if field_name is None:
|
||||
continue
|
||||
normalized_field = _COMMUNITY_MQTT_TEMPLATE_FIELD_CANONICAL.get(field_name.lower())
|
||||
if normalized_field is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"topic_template may only use {{IATA}} and {{PUBLIC_KEY}}; got {field_name}"
|
||||
),
|
||||
)
|
||||
replacement = ["{", normalized_field]
|
||||
if conversion:
|
||||
replacement.extend(["!", conversion])
|
||||
if format_spec:
|
||||
replacement.extend([":", format_spec])
|
||||
replacement.append("}")
|
||||
parts.append("".join(replacement))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid topic_template: {exc}") from None
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class FanoutConfigCreate(BaseModel):
|
||||
type: str = Field(description="Integration type: 'mqtt_private' or 'mqtt_community'")
|
||||
name: str = Field(min_length=1, description="User-assigned label")
|
||||
config: dict = Field(default_factory=dict, description="Type-specific config blob")
|
||||
scope: dict = Field(default_factory=dict, description="Scope controls")
|
||||
enabled: bool = Field(default=True, description="Whether enabled on creation")
|
||||
|
||||
|
||||
class FanoutConfigUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, description="Updated label")
|
||||
config: dict | None = Field(default=None, description="Updated config blob")
|
||||
scope: dict | None = Field(default=None, description="Updated scope controls")
|
||||
enabled: bool | None = Field(default=None, description="Enable/disable toggle")
|
||||
|
||||
|
||||
def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
|
||||
"""Validate a config blob and return the canonical persisted form."""
|
||||
normalized = dict(config)
|
||||
|
||||
if config_type == "mqtt_private":
|
||||
_validate_mqtt_private_config(normalized)
|
||||
elif config_type == "mqtt_community":
|
||||
_validate_mqtt_community_config(normalized)
|
||||
elif config_type == "bot":
|
||||
_validate_bot_config(normalized)
|
||||
elif config_type == "webhook":
|
||||
_validate_webhook_config(normalized)
|
||||
elif config_type == "apprise":
|
||||
_validate_apprise_config(normalized)
|
||||
elif config_type == "sqs":
|
||||
_validate_sqs_config(normalized)
|
||||
elif config_type == "map_upload":
|
||||
_validate_map_upload_config(normalized)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_mqtt_private_config(config: dict) -> None:
|
||||
"""Validate mqtt_private config blob."""
|
||||
if not config.get("broker_host"):
|
||||
raise HTTPException(status_code=400, detail="broker_host is required for mqtt_private")
|
||||
port = config.get("broker_port", 1883)
|
||||
if not isinstance(port, int) or port < 1 or port > 65535:
|
||||
raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
|
||||
|
||||
|
||||
def _validate_mqtt_community_config(config: dict) -> None:
|
||||
"""Validate mqtt_community config blob. Normalizes IATA to uppercase."""
|
||||
broker_host = str(config.get("broker_host", _DEFAULT_COMMUNITY_MQTT_BROKER_HOST)).strip()
|
||||
if not broker_host:
|
||||
broker_host = _DEFAULT_COMMUNITY_MQTT_BROKER_HOST
|
||||
config["broker_host"] = broker_host
|
||||
|
||||
port = config.get("broker_port", _DEFAULT_COMMUNITY_MQTT_BROKER_PORT)
|
||||
if not isinstance(port, int) or port < 1 or port > 65535:
|
||||
raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
|
||||
config["broker_port"] = port
|
||||
|
||||
transport = str(config.get("transport", _DEFAULT_COMMUNITY_MQTT_TRANSPORT)).strip().lower()
|
||||
if transport not in _ALLOWED_COMMUNITY_MQTT_TRANSPORTS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="transport must be 'websockets' or 'tcp'",
|
||||
)
|
||||
config["transport"] = transport
|
||||
config["use_tls"] = bool(config.get("use_tls", True))
|
||||
config["tls_verify"] = bool(config.get("tls_verify", True))
|
||||
|
||||
auth_mode = str(config.get("auth_mode", _DEFAULT_COMMUNITY_MQTT_AUTH_MODE)).strip().lower()
|
||||
if auth_mode not in _ALLOWED_COMMUNITY_MQTT_AUTH_MODES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="auth_mode must be 'token', 'password', or 'none'",
|
||||
)
|
||||
config["auth_mode"] = auth_mode
|
||||
username = str(config.get("username", "")).strip()
|
||||
password = str(config.get("password", "")).strip()
|
||||
if auth_mode == "password" and (not username or not password):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="username and password are required when auth_mode is 'password'",
|
||||
)
|
||||
config["username"] = username
|
||||
config["password"] = password
|
||||
|
||||
token_audience = str(config.get("token_audience", "")).strip()
|
||||
config["token_audience"] = token_audience
|
||||
|
||||
iata = config.get("iata", "").upper().strip()
|
||||
if not iata or not _IATA_RE.fullmatch(iata):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="IATA code is required and must be exactly 3 uppercase alphabetic characters",
|
||||
)
|
||||
config["iata"] = iata
|
||||
|
||||
topic_template = str(
|
||||
config.get("topic_template", _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE)
|
||||
).strip()
|
||||
if not topic_template:
|
||||
topic_template = _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE
|
||||
|
||||
config["topic_template"] = _normalize_community_topic_template(topic_template)
|
||||
|
||||
|
||||
def _validate_bot_config(config: dict) -> None:
|
||||
"""Validate bot config blob (syntax-check the code and supported signature)."""
|
||||
code = config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
|
||||
try:
|
||||
tree = ast.parse(code, filename="<bot_code>", mode="exec")
|
||||
except SyntaxError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
bot_def = next(
|
||||
(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "bot"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if bot_def is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Bot code must define a callable bot() function. "
|
||||
"Use the default bot template as a reference."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
parameters: list[inspect.Parameter] = []
|
||||
positional_args = [
|
||||
*((arg, inspect.Parameter.POSITIONAL_ONLY) for arg in bot_def.args.posonlyargs),
|
||||
*((arg, inspect.Parameter.POSITIONAL_OR_KEYWORD) for arg in bot_def.args.args),
|
||||
]
|
||||
positional_defaults_start = len(positional_args) - len(bot_def.args.defaults)
|
||||
sentinel_default = object()
|
||||
|
||||
for index, (arg, kind) in enumerate(positional_args):
|
||||
has_default = index >= positional_defaults_start
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
arg.arg,
|
||||
kind=kind,
|
||||
default=sentinel_default if has_default else inspect.Parameter.empty,
|
||||
)
|
||||
)
|
||||
if bot_def.args.vararg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.vararg.arg, kind=inspect.Parameter.VAR_POSITIONAL)
|
||||
)
|
||||
for kwonly_arg, kw_default in zip(
|
||||
bot_def.args.kwonlyargs, bot_def.args.kw_defaults, strict=True
|
||||
):
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
kwonly_arg.arg,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
default=(
|
||||
sentinel_default if kw_default is not None else inspect.Parameter.empty
|
||||
),
|
||||
)
|
||||
)
|
||||
if bot_def.args.kwarg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.kwarg.arg, kind=inspect.Parameter.VAR_KEYWORD)
|
||||
)
|
||||
|
||||
_analyze_bot_signature(inspect.Signature(parameters))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
|
||||
|
||||
def _validate_apprise_config(config: dict) -> None:
|
||||
"""Validate apprise config blob."""
|
||||
urls = config.get("urls", "")
|
||||
if not urls or not urls.strip():
|
||||
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
||||
|
||||
|
||||
def _validate_webhook_config(config: dict) -> None:
|
||||
"""Validate webhook config blob."""
|
||||
url = config.get("url", "")
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="url is required for webhook")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="url must start with http:// or https://")
|
||||
method = config.get("method", "POST").upper()
|
||||
if method not in ("POST", "PUT", "PATCH"):
|
||||
raise HTTPException(status_code=400, detail="method must be POST, PUT, or PATCH")
|
||||
headers = config.get("headers", {})
|
||||
if not isinstance(headers, dict):
|
||||
raise HTTPException(status_code=400, detail="headers must be a JSON object")
|
||||
|
||||
|
||||
def _validate_sqs_config(config: dict) -> None:
|
||||
"""Validate sqs config blob."""
|
||||
queue_url = str(config.get("queue_url", "")).strip()
|
||||
if not queue_url:
|
||||
raise HTTPException(status_code=400, detail="queue_url is required for sqs")
|
||||
if not queue_url.startswith(("https://", "http://")):
|
||||
raise HTTPException(status_code=400, detail="queue_url must start with http:// or https://")
|
||||
|
||||
endpoint_url = str(config.get("endpoint_url", "")).strip()
|
||||
if endpoint_url and not endpoint_url.startswith(("https://", "http://")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="endpoint_url must start with http:// or https://",
|
||||
)
|
||||
|
||||
access_key_id = str(config.get("access_key_id", "")).strip()
|
||||
secret_access_key = str(config.get("secret_access_key", "")).strip()
|
||||
session_token = str(config.get("session_token", "")).strip()
|
||||
has_static_keypair = bool(access_key_id) and bool(secret_access_key)
|
||||
has_partial_keypair = bool(access_key_id) != bool(secret_access_key)
|
||||
|
||||
if has_partial_keypair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="access_key_id and secret_access_key must be set together for sqs",
|
||||
)
|
||||
if session_token and not has_static_keypair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="session_token requires access_key_id and secret_access_key for sqs",
|
||||
)
|
||||
|
||||
|
||||
def _validate_map_upload_config(config: dict) -> None:
|
||||
"""Validate and normalize map_upload config blob."""
|
||||
api_url = str(config.get("api_url", "")).strip()
|
||||
if api_url and not api_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="api_url must start with http:// or https://",
|
||||
)
|
||||
# Persist the cleaned value (empty string means use the module default)
|
||||
config["api_url"] = api_url
|
||||
config["dry_run"] = bool(config.get("dry_run", True))
|
||||
config["geofence_enabled"] = bool(config.get("geofence_enabled", False))
|
||||
try:
|
||||
radius = float(config.get("geofence_radius_km", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="geofence_radius_km must be a number") from None
|
||||
if radius < 0:
|
||||
raise HTTPException(status_code=400, detail="geofence_radius_km must be >= 0")
|
||||
config["geofence_radius_km"] = radius
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "map_upload":
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "bot":
|
||||
return {"messages": "all", "raw_packets": "none"}
|
||||
if config_type in ("webhook", "apprise"):
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="scope.messages must be 'all', 'none', or a filter object",
|
||||
)
|
||||
return {"messages": messages, "raw_packets": "none"}
|
||||
# For mqtt_private and sqs, validate scope values
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="scope.messages must be 'all', 'none', or a filter object",
|
||||
)
|
||||
raw_packets = scope.get("raw_packets", "all")
|
||||
if raw_packets not in ("all", "none"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="scope.raw_packets must be 'all' or 'none'",
|
||||
)
|
||||
return {"messages": messages, "raw_packets": raw_packets}
|
||||
|
||||
|
||||
def _bot_system_disabled_detail() -> str | None:
|
||||
source = fanout_manager.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
|
||||
if source == "until_restart":
|
||||
return "Bot system disabled until the server restarts"
|
||||
return None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_fanout_configs() -> list[dict]:
|
||||
"""List all fanout configs."""
|
||||
return await FanoutConfigRepository.get_all()
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
"""Create a new fanout config."""
|
||||
if body.type not in _VALID_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
||||
)
|
||||
|
||||
if body.type == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
normalized_config = _validate_and_normalize_config(body.type, body.config)
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type=body.type,
|
||||
name=body.name,
|
||||
config=normalized_config,
|
||||
scope=scope,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
|
||||
# Start the module if enabled
|
||||
if cfg["enabled"]:
|
||||
await fanout_manager.reload_config(cfg["id"])
|
||||
|
||||
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.patch("/{config_id}")
|
||||
async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict:
|
||||
"""Update a fanout config. Triggers module reload."""
|
||||
existing = await FanoutConfigRepository.get(config_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
if existing["type"] == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
kwargs = {}
|
||||
if body.name is not None:
|
||||
kwargs["name"] = body.name
|
||||
if body.enabled is not None:
|
||||
kwargs["enabled"] = body.enabled
|
||||
if body.scope is not None:
|
||||
kwargs["scope"] = _enforce_scope(existing["type"], body.scope)
|
||||
|
||||
config_to_validate = body.config if body.config is not None else existing["config"]
|
||||
kwargs["config"] = _validate_and_normalize_config(existing["type"], config_to_validate)
|
||||
|
||||
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Reload the module to pick up changes
|
||||
await fanout_manager.reload_config(config_id)
|
||||
|
||||
logger.info("Updated fanout config %s", config_id)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{config_id}")
|
||||
async def delete_fanout_config(config_id: str) -> dict:
|
||||
"""Delete a fanout config."""
|
||||
existing = await FanoutConfigRepository.get(config_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Stop the module first
|
||||
await fanout_manager.remove_config(config_id)
|
||||
await FanoutConfigRepository.delete(config_id)
|
||||
|
||||
logger.info("Deleted fanout config %s", config_id)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/bots/disable-until-restart")
|
||||
async def disable_bots_until_restart() -> dict:
|
||||
"""Stop active bot modules and prevent them from running again until restart."""
|
||||
source = await fanout_manager.disable_bots_until_restart()
|
||||
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
return {
|
||||
"status": "ok",
|
||||
"bots_disabled": True,
|
||||
"bots_disabled_source": source,
|
||||
}
|
||||
+96
-24
@@ -1,27 +1,68 @@
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.radio import radio_manager
|
||||
from app.repository import RawPacketRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
class RadioDeviceInfoResponse(BaseModel):
|
||||
model: str | None = None
|
||||
firmware_build: str | None = None
|
||||
firmware_version: str | None = None
|
||||
max_contacts: int | None = None
|
||||
max_channels: int | None = None
|
||||
|
||||
|
||||
class AppInfoResponse(BaseModel):
|
||||
version: str
|
||||
commit_hash: str | None = None
|
||||
|
||||
|
||||
class FanoutStatusResponse(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
status: str
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
radio_initializing: bool = False
|
||||
radio_state: str = "disconnected"
|
||||
connection_info: str | None
|
||||
app_info: AppInfoResponse | None = None
|
||||
radio_device_info: RadioDeviceInfoResponse | None = None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
mqtt_status: str | None = None
|
||||
community_mqtt_status: str | None = None
|
||||
fanout_statuses: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||
bots_disabled: bool = False
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
def _clean_optional_str(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _read_optional_bool_setting(name: str) -> bool:
|
||||
value = getattr(settings, name, False)
|
||||
return value if isinstance(value, bool) else False
|
||||
|
||||
|
||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
|
||||
app_build_info = get_app_build_info()
|
||||
db_size_mb = 0.0
|
||||
try:
|
||||
db_size_bytes = os.path.getsize(settings.database_path)
|
||||
@@ -35,38 +76,69 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
except RuntimeError:
|
||||
pass # Database not connected
|
||||
|
||||
# MQTT status
|
||||
mqtt_status: str | None = None
|
||||
# Fanout module statuses
|
||||
fanout_statuses: dict[str, Any] = {}
|
||||
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
|
||||
try:
|
||||
from app.mqtt import mqtt_publisher
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
if mqtt_publisher._is_configured():
|
||||
mqtt_status = "connected" if mqtt_publisher.connected else "disconnected"
|
||||
else:
|
||||
mqtt_status = "disabled"
|
||||
fanout_statuses = fanout_manager.get_statuses()
|
||||
manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
|
||||
if manager_bots_disabled_source is not None:
|
||||
bots_disabled_source = manager_bots_disabled_source
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Community MQTT status
|
||||
community_mqtt_status: str | None = None
|
||||
try:
|
||||
from app.community_mqtt import community_publisher
|
||||
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
|
||||
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
|
||||
if not radio_connected:
|
||||
setup_complete = False
|
||||
|
||||
if community_publisher._is_configured():
|
||||
community_mqtt_status = "connected" if community_publisher.connected else "disconnected"
|
||||
else:
|
||||
community_mqtt_status = "disabled"
|
||||
except Exception:
|
||||
pass
|
||||
connection_desired = getattr(radio_manager, "connection_desired", True)
|
||||
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
if not connection_desired:
|
||||
radio_state = "paused"
|
||||
elif radio_initializing:
|
||||
radio_state = "initializing"
|
||||
elif radio_connected:
|
||||
radio_state = "connected"
|
||||
elif is_reconnecting:
|
||||
radio_state = "connecting"
|
||||
else:
|
||||
radio_state = "disconnected"
|
||||
|
||||
radio_device_info = None
|
||||
device_info_loaded = getattr(radio_manager, "device_info_loaded", False)
|
||||
if radio_connected and device_info_loaded:
|
||||
radio_device_info = {
|
||||
"model": _clean_optional_str(getattr(radio_manager, "device_model", None)),
|
||||
"firmware_build": _clean_optional_str(getattr(radio_manager, "firmware_build", None)),
|
||||
"firmware_version": _clean_optional_str(
|
||||
getattr(radio_manager, "firmware_version", None)
|
||||
),
|
||||
"max_contacts": getattr(radio_manager, "max_contacts", None),
|
||||
"max_channels": getattr(radio_manager, "max_channels", None),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"radio_initializing": radio_initializing,
|
||||
"radio_state": radio_state,
|
||||
"connection_info": connection_info,
|
||||
"app_info": {
|
||||
"version": app_build_info.version,
|
||||
"commit_hash": app_build_info.commit_hash,
|
||||
},
|
||||
"radio_device_info": radio_device_info,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"mqtt_status": mqtt_status,
|
||||
"community_mqtt_status": community_mqtt_status,
|
||||
"fanout_statuses": fanout_statuses,
|
||||
"bots_disabled": bots_disabled_source is not None,
|
||||
"bots_disabled_source": bots_disabled_source,
|
||||
"basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+97
-304
@@ -1,21 +1,51 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
from app.models import (
|
||||
Message,
|
||||
MessagesAroundResponse,
|
||||
ResendChannelMessageResponse,
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
|
||||
from app.services.message_send import (
|
||||
resend_channel_message_record,
|
||||
send_channel_message_to_channel,
|
||||
send_direct_message_to_contact,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
|
||||
@router.get("/around/{message_id}", response_model=MessagesAroundResponse)
|
||||
async def get_messages_around(
|
||||
message_id: int,
|
||||
type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"),
|
||||
conversation_key: str | None = Query(default=None, description="Filter by conversation key"),
|
||||
context: int = Query(default=100, ge=1, le=500, description="Number of messages before/after"),
|
||||
) -> MessagesAroundResponse:
|
||||
"""Get messages around a specific message for jump-to-message navigation."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
messages, has_older, has_newer = await MessageRepository.get_around(
|
||||
message_id=message_id,
|
||||
msg_type=type,
|
||||
conversation_key=conversation_key,
|
||||
context_size=context,
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
return MessagesAroundResponse(messages=messages, has_older=has_older, has_newer=has_newer)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Message])
|
||||
async def list_messages(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -28,8 +58,18 @@ async def list_messages(
|
||||
default=None, description="Cursor: received_at of last seen message"
|
||||
),
|
||||
before_id: int | None = Query(default=None, description="Cursor: id of last seen message"),
|
||||
after: int | None = Query(
|
||||
default=None, description="Forward cursor: received_at of last seen message"
|
||||
),
|
||||
after_id: int | None = Query(
|
||||
default=None, description="Forward cursor: id of last seen message"
|
||||
),
|
||||
q: str | None = Query(default=None, description="Full-text search query"),
|
||||
) -> list[Message]:
|
||||
"""List messages from the database."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
return await MessageRepository.get_all(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -37,13 +77,18 @@ async def list_messages(
|
||||
conversation_key=conversation_key,
|
||||
before=before,
|
||||
before_id=before_id,
|
||||
after=after,
|
||||
after_id=after_id,
|
||||
q=q,
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/direct", response_model=Message)
|
||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
"""Send a direct message to a contact."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# First check our database for the contact
|
||||
from app.repository import ContactRepository
|
||||
@@ -63,110 +108,36 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Contact not found in database: {request.destination}"
|
||||
)
|
||||
|
||||
# Always add/update the contact on radio before sending.
|
||||
# The library cache (get_contact_by_key_prefix) can be stale after radio reboot,
|
||||
# so we can't rely on it to know if the firmware has the contact.
|
||||
# add_contact is idempotent - updates if exists, adds if not.
|
||||
contact_data = db_contact.to_radio_dict()
|
||||
async with radio_manager.radio_operation("send_direct_message") as mc:
|
||||
logger.debug("Ensuring contact %s is on radio before sending", db_contact.public_key[:12])
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
logger.warning("Failed to add contact to radio: %s", add_result.payload)
|
||||
# Continue anyway - might still work if contact exists
|
||||
|
||||
# Get the contact from the library cache (may have updated info like path)
|
||||
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
|
||||
if not contact:
|
||||
contact = contact_data
|
||||
|
||||
logger.info("Sending direct message to %s", db_contact.public_key[:12])
|
||||
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures consistency for deduplication.
|
||||
now = int(time.time())
|
||||
|
||||
result = await mc.commands.send_msg(
|
||||
dst=contact,
|
||||
msg=request.text,
|
||||
timestamp=now,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing message
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=request.text,
|
||||
conversation_key=db_contact.public_key.lower(),
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if message_id is None:
|
||||
if len(db_contact.public_key) < 64:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
status_code=409,
|
||||
detail="Cannot send to an unresolved prefix-only contact until a full key is known",
|
||||
)
|
||||
|
||||
# Update last_contacted for the contact
|
||||
await ContactRepository.update_last_contacted(db_contact.public_key.lower(), now)
|
||||
|
||||
# Track the expected ACK for this message
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout: int = result.payload.get("suggested_timeout", 10000) # default 10s
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack(ack_code, message_id, suggested_timeout)
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
|
||||
|
||||
message = Message(
|
||||
id=message_id,
|
||||
type="PRIV",
|
||||
conversation_key=db_contact.public_key.lower(),
|
||||
return await send_direct_message_to_contact(
|
||||
contact=db_contact,
|
||||
text=request.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
track_pending_ack_fn=track_pending_ack,
|
||||
now_fn=time.time,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=None,
|
||||
sender_key=db_contact.public_key.lower(),
|
||||
message_text=request.text,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=now,
|
||||
path=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# Temporary radio slot used for sending channel messages
|
||||
# Preferred first radio slot used for sending channel messages.
|
||||
# The send service may reuse/load other app-managed slots depending on transport
|
||||
# and session cache state.
|
||||
TEMP_RADIO_SLOT = 0
|
||||
|
||||
|
||||
@router.post("/channel", response_model=Message)
|
||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
"""Send a message to a channel."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.decoder import calculate_channel_hash
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(request.channel_key)
|
||||
@@ -183,135 +154,32 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
status_code=400, detail=f"Invalid channel key format: {request.channel_key}"
|
||||
) from None
|
||||
|
||||
expected_hash = calculate_channel_hash(key_bytes)
|
||||
logger.info(
|
||||
"Sending to channel %s (%s) via radio slot %d, key hash: %s",
|
||||
request.channel_key,
|
||||
db_channel.name,
|
||||
TEMP_RADIO_SLOT,
|
||||
expected_hash,
|
||||
return await send_channel_message_to_channel(
|
||||
channel=db_channel,
|
||||
channel_key_upper=request.channel_key.upper(),
|
||||
key_bytes=key_bytes,
|
||||
text=request.text,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
error_broadcast_fn=broadcast_error,
|
||||
now_fn=time.time,
|
||||
temp_radio_slot=TEMP_RADIO_SLOT,
|
||||
message_repository=MessageRepository,
|
||||
)
|
||||
channel_key_upper = request.channel_key.upper()
|
||||
message_id: int | None = None
|
||||
now: int | None = None
|
||||
radio_name: str = ""
|
||||
text_with_sender: str = request.text
|
||||
|
||||
async with radio_manager.radio_operation("send_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
|
||||
# Load the channel to a temporary radio slot before sending
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=TEMP_RADIO_SLOT,
|
||||
channel_name=db_channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before sending: %s",
|
||||
TEMP_RADIO_SLOT,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to configure channel on radio before sending message",
|
||||
)
|
||||
|
||||
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
|
||||
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures the echo's timestamp matches our stored message
|
||||
# for proper deduplication.
|
||||
now = int(time.time())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=request.text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing immediately after send to avoid a race where
|
||||
# our own echo lands before persistence.
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text_with_sender,
|
||||
conversation_key=channel_key_upper,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
# Broadcast immediately so all connected clients see the message promptly.
|
||||
# This ensures the message exists in frontend state when echo-driven
|
||||
# `message_acked` events arrive.
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=message_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
if message_id is None or now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
acked_count, paths = await MessageRepository.get_ack_and_paths(message_id)
|
||||
|
||||
message = Message(
|
||||
id=message_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
paths=paths,
|
||||
)
|
||||
|
||||
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=radio_name or None,
|
||||
sender_key=None,
|
||||
message_text=request.text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_upper,
|
||||
channel_name=db_channel.name,
|
||||
sender_timestamp=now,
|
||||
path=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
|
||||
@router.post("/channel/{message_id}/resend")
|
||||
@router.post(
|
||||
"/channel/{message_id}/resend",
|
||||
response_model=ResendChannelMessageResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def resend_channel_message(
|
||||
message_id: int,
|
||||
new_timestamp: bool = Query(default=False),
|
||||
) -> dict:
|
||||
) -> ResendChannelMessageResponse:
|
||||
"""Resend a channel message.
|
||||
|
||||
When new_timestamp=False (default): byte-perfect resend using the original timestamp.
|
||||
@@ -320,7 +188,7 @@ async def resend_channel_message(
|
||||
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
|
||||
new packet. Creates a new message row in the database. No time window restriction.
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
@@ -347,89 +215,14 @@ async def resend_channel_message(
|
||||
if not db_channel:
|
||||
raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
|
||||
|
||||
# Choose timestamp: original for byte-perfect, fresh for new-timestamp
|
||||
if new_timestamp:
|
||||
now = int(time.time())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
else:
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
|
||||
) from None
|
||||
|
||||
async with radio_manager.radio_operation("resend_channel_message") as mc:
|
||||
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_to_send = msg.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=TEMP_RADIO_SLOT,
|
||||
channel_name=db_channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to configure channel on radio before resending",
|
||||
)
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=text_to_send,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to resend message: {result.payload}"
|
||||
)
|
||||
|
||||
# For new-timestamp resend, create a new message row and broadcast it
|
||||
if new_timestamp:
|
||||
new_msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=msg.text,
|
||||
conversation_key=msg.conversation_key,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if new_msg_id is None:
|
||||
# Timestamp-second collision (same text+channel within the same second).
|
||||
# The radio already transmitted, so log and return the original ID rather
|
||||
# than surfacing a 500 for a message that was successfully sent over the air.
|
||||
logger.warning(
|
||||
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
|
||||
message_id,
|
||||
)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=new_msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=msg.conversation_key,
|
||||
text=msg.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message_id,
|
||||
new_msg_id,
|
||||
db_channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_msg_id}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message_id, db_channel.name)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
return await resend_channel_message_record(
|
||||
message=msg,
|
||||
channel=db_channel,
|
||||
new_timestamp=new_timestamp,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
error_broadcast_fn=broadcast_error,
|
||||
now_fn=time.time,
|
||||
temp_radio_slot=TEMP_RADIO_SLOT,
|
||||
message_repository=MessageRepository,
|
||||
)
|
||||
|
||||
+68
-59
@@ -3,13 +3,14 @@ from hashlib import sha256
|
||||
from sqlite3 import OperationalError
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import RawPacketDecryptedInfo, RawPacketDetail
|
||||
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
|
||||
from app.repository import ChannelRepository, RawPacketRepository
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,12 +41,15 @@ class DecryptResult(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
def _bad_request(detail: str) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
|
||||
async def _run_historical_channel_decryption(
|
||||
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
|
||||
) -> None:
|
||||
"""Background task to decrypt historical packets with a channel key."""
|
||||
packets = await RawPacketRepository.get_all_undecrypted()
|
||||
total = len(packets)
|
||||
total = await RawPacketRepository.get_undecrypted_count()
|
||||
decrypted_count = 0
|
||||
|
||||
if total == 0:
|
||||
@@ -54,7 +58,11 @@ async def _run_historical_channel_decryption(
|
||||
|
||||
logger.info("Starting historical channel decryption of %d packets", total)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_all_undecrypted():
|
||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||
|
||||
if result is not None:
|
||||
@@ -71,7 +79,8 @@ async def _run_historical_channel_decryption(
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
trigger_bot=False, # Historical decryption should not trigger bot
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
realtime=False, # Historical decryption should not trigger fanout
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
@@ -97,9 +106,48 @@ async def get_undecrypted_count() -> dict:
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.get("/{packet_id}", response_model=RawPacketDetail)
|
||||
async def get_raw_packet(packet_id: int) -> RawPacketDetail:
|
||||
"""Fetch one stored raw packet by row ID for on-demand inspection."""
|
||||
packet_row = await RawPacketRepository.get_by_id(packet_id)
|
||||
if packet_row is None:
|
||||
raise HTTPException(status_code=404, detail="Raw packet not found")
|
||||
|
||||
stored_packet_id, packet_data, packet_timestamp, message_id = packet_row
|
||||
packet_info = parse_packet(packet_data)
|
||||
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
|
||||
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
if message_id is not None:
|
||||
message = await MessageRepository.get_by_id(message_id)
|
||||
if message is not None:
|
||||
if message.type == "CHAN":
|
||||
channel = await ChannelRepository.get_by_key(message.conversation_key)
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
channel_name=channel.name if channel else None,
|
||||
sender=message.sender_name,
|
||||
channel_key=message.conversation_key,
|
||||
contact_key=message.sender_key,
|
||||
)
|
||||
else:
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
sender=message.sender_name,
|
||||
contact_key=message.conversation_key,
|
||||
)
|
||||
|
||||
return RawPacketDetail(
|
||||
id=stored_packet_id,
|
||||
timestamp=packet_timestamp,
|
||||
data=packet_data.hex(),
|
||||
payload_type=payload_type_name,
|
||||
decrypted=message_id is not None,
|
||||
decrypted_info=decrypted_info,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/decrypt/historical", response_model=DecryptResult)
|
||||
async def decrypt_historical_packets(
|
||||
request: DecryptRequest, background_tasks: BackgroundTasks
|
||||
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
|
||||
) -> DecryptResult:
|
||||
"""
|
||||
Attempt to decrypt historical packets with the provided key.
|
||||
@@ -111,27 +159,15 @@ async def decrypt_historical_packets(
|
||||
try:
|
||||
channel_key_bytes = bytes.fromhex(request.channel_key)
|
||||
if len(channel_key_bytes) != 16:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Channel key must be 16 bytes (32 hex chars)",
|
||||
)
|
||||
raise _bad_request("Channel key must be 16 bytes (32 hex chars)")
|
||||
channel_key_hex = request.channel_key.upper()
|
||||
except ValueError:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Invalid hex string for channel key",
|
||||
)
|
||||
raise _bad_request("Invalid hex string for channel key") from None
|
||||
elif request.channel_name:
|
||||
channel_key_bytes = sha256(request.channel_name.encode("utf-8")).digest()[:16]
|
||||
channel_key_hex = channel_key_bytes.hex().upper()
|
||||
else:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Must provide channel_key or channel_name",
|
||||
)
|
||||
raise _bad_request("Must provide channel_key or channel_name")
|
||||
|
||||
# Get count and lookup channel name for display
|
||||
count = await RawPacketRepository.get_undecrypted_count()
|
||||
@@ -147,6 +183,7 @@ async def decrypt_historical_packets(
|
||||
background_tasks.add_task(
|
||||
_run_historical_channel_decryption, channel_key_bytes, channel_key_hex, display_name
|
||||
)
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
|
||||
return DecryptResult(
|
||||
started=True,
|
||||
@@ -157,51 +194,26 @@ async def decrypt_historical_packets(
|
||||
elif request.key_type == "contact":
|
||||
# DM decryption
|
||||
if not request.private_key:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Must provide private_key for contact decryption",
|
||||
)
|
||||
raise _bad_request("Must provide private_key for contact decryption")
|
||||
if not request.contact_public_key:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Must provide contact_public_key for contact decryption",
|
||||
)
|
||||
raise _bad_request("Must provide contact_public_key for contact decryption")
|
||||
|
||||
try:
|
||||
private_key_bytes = bytes.fromhex(request.private_key)
|
||||
if len(private_key_bytes) != 64:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Private key must be 64 bytes (128 hex chars)",
|
||||
)
|
||||
raise _bad_request("Private key must be 64 bytes (128 hex chars)")
|
||||
except ValueError:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Invalid hex string for private key",
|
||||
)
|
||||
raise _bad_request("Invalid hex string for private key") from None
|
||||
|
||||
try:
|
||||
contact_public_key_bytes = bytes.fromhex(request.contact_public_key)
|
||||
if len(contact_public_key_bytes) != 32:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Contact public key must be 32 bytes (64 hex chars)",
|
||||
)
|
||||
raise _bad_request("Contact public key must be 32 bytes (64 hex chars)")
|
||||
contact_public_key_hex = request.contact_public_key.lower()
|
||||
except ValueError:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Invalid hex string for contact public key",
|
||||
)
|
||||
raise _bad_request("Invalid hex string for contact public key") from None
|
||||
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
count = len(packets)
|
||||
count = await RawPacketRepository.count_undecrypted_text_messages()
|
||||
if count == 0:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
@@ -222,6 +234,7 @@ async def decrypt_historical_packets(
|
||||
contact_public_key_hex,
|
||||
display_name,
|
||||
)
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
|
||||
return DecryptResult(
|
||||
started=True,
|
||||
@@ -229,11 +242,7 @@ async def decrypt_historical_packets(
|
||||
message=f"Started DM decryption of {count} TEXT_MESSAGE packets in background",
|
||||
)
|
||||
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="key_type must be 'channel' or 'contact'",
|
||||
)
|
||||
raise _bad_request("key_type must be 'channel' or 'contact'")
|
||||
|
||||
|
||||
class MaintenanceRequest(BaseModel):
|
||||
|
||||
+522
-80
@@ -1,17 +1,76 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.radio import radio_manager
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
ContactUpsert,
|
||||
RadioDiscoveryRequest,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryResult,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceNode,
|
||||
RadioTraceRequest,
|
||||
RadioTraceResponse,
|
||||
)
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.server_control import _monotonic
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
RadioCommandRejectedError,
|
||||
apply_radio_config_update,
|
||||
import_private_key_and_refresh_keystore,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_event, broadcast_health
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
AdvertLocationSource = Literal["off", "current"]
|
||||
RadioAdvertMode = Literal["flood", "zero_hop"]
|
||||
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
|
||||
DISCOVERY_WINDOW_SECONDS = 8.0
|
||||
_DISCOVERY_TARGET_BITS = {
|
||||
"repeaters": 1 << 2,
|
||||
"sensors": 1 << 4,
|
||||
"all": (1 << 2) | (1 << 4),
|
||||
}
|
||||
_DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
|
||||
2: "repeater",
|
||||
4: "sensor",
|
||||
}
|
||||
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
|
||||
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
|
||||
TRACE_TIMEOUT_MIN_SECONDS = 5.0
|
||||
TRACE_TIMEOUT_MAX_SECONDS = 30.0
|
||||
TRACE_TIMEOUT_MARGIN = 1.2
|
||||
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
|
||||
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
|
||||
|
||||
|
||||
async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool:
|
||||
return await radio_manager.reconnect_and_prepare(
|
||||
broadcast_on_success=broadcast_on_success,
|
||||
)
|
||||
|
||||
|
||||
class RadioSettings(BaseModel):
|
||||
freq: float = Field(description="Frequency in MHz")
|
||||
@@ -28,6 +87,20 @@ class RadioConfigResponse(BaseModel):
|
||||
tx_power: int = Field(description="Transmit power in dBm")
|
||||
max_tx_power: int = Field(description="Maximum transmit power in dBm")
|
||||
radio: RadioSettings
|
||||
path_hash_mode: int = Field(
|
||||
default=0, description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)"
|
||||
)
|
||||
path_hash_mode_supported: bool = Field(
|
||||
default=False, description="Whether firmware supports path hash mode setting"
|
||||
)
|
||||
advert_location_source: AdvertLocationSource = Field(
|
||||
default="current",
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
multi_acks_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -36,21 +109,240 @@ class RadioConfigUpdate(BaseModel):
|
||||
lon: float | None = None
|
||||
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
|
||||
radio: RadioSettings | None = None
|
||||
path_hash_mode: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=2,
|
||||
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)",
|
||||
)
|
||||
advert_location_source: AdvertLocationSource | None = Field(
|
||||
default=None,
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
multi_acks_enabled: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
private_key: str = Field(description="Private key as hex string")
|
||||
|
||||
|
||||
class RadioAdvertiseRequest(BaseModel):
|
||||
mode: RadioAdvertMode = Field(
|
||||
default="flood",
|
||||
description="Advertisement mode: flood through repeaters or zero-hop local only",
|
||||
)
|
||||
|
||||
|
||||
def _better_signal(first: float | None, second: float | None) -> float | None:
|
||||
if first is None:
|
||||
return second
|
||||
if second is None:
|
||||
return first
|
||||
return second if second > first else first
|
||||
|
||||
|
||||
def _coerce_float(value: object) -> float | None:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_int(value: object) -> int | None:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _merge_discovery_result(
|
||||
existing: RadioDiscoveryResult | None, event_payload: dict[str, object]
|
||||
) -> RadioDiscoveryResult | None:
|
||||
public_key = event_payload.get("pubkey")
|
||||
node_type_code = event_payload.get("node_type")
|
||||
if not isinstance(public_key, str) or not public_key:
|
||||
return existing
|
||||
if not isinstance(node_type_code, int):
|
||||
return existing
|
||||
|
||||
node_type = _DISCOVERY_NODE_TYPES.get(node_type_code)
|
||||
if node_type is None:
|
||||
return existing
|
||||
|
||||
if existing is None:
|
||||
return RadioDiscoveryResult(
|
||||
public_key=public_key,
|
||||
node_type=node_type,
|
||||
heard_count=1,
|
||||
local_snr=_coerce_float(event_payload.get("SNR")),
|
||||
local_rssi=_coerce_int(event_payload.get("RSSI")),
|
||||
remote_snr=_coerce_float(event_payload.get("SNR_in")),
|
||||
)
|
||||
|
||||
existing.heard_count += 1
|
||||
existing.local_snr = _better_signal(existing.local_snr, _coerce_float(event_payload.get("SNR")))
|
||||
current_rssi = _coerce_int(event_payload.get("RSSI"))
|
||||
if existing.local_rssi is None or (
|
||||
current_rssi is not None and current_rssi > existing.local_rssi
|
||||
):
|
||||
existing.local_rssi = current_rssi
|
||||
existing.remote_snr = _better_signal(
|
||||
existing.remote_snr,
|
||||
_coerce_float(event_payload.get("SNR_in")),
|
||||
)
|
||||
return existing
|
||||
|
||||
|
||||
async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -> None:
|
||||
now = int(time.time())
|
||||
for result in results:
|
||||
existing = await ContactRepository.get_by_key(result.public_key)
|
||||
if existing is not None:
|
||||
continue
|
||||
|
||||
contact = ContactUpsert(
|
||||
public_key=result.public_key,
|
||||
type=2 if result.node_type == "repeater" else 4,
|
||||
last_seen=now,
|
||||
first_seen=now,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=result.public_key,
|
||||
log=logger,
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=result.public_key,
|
||||
contact_name=result.name,
|
||||
log=logger,
|
||||
)
|
||||
created = await ContactRepository.get_by_key(result.public_key)
|
||||
if created is not None:
|
||||
broadcast_event("contact", created.model_dump())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||
)
|
||||
|
||||
|
||||
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||
"""Resolve known contact names for discovery results from the DB."""
|
||||
for result in results:
|
||||
contact = await ContactRepository.get_by_key(result.public_key)
|
||||
if contact is not None and contact.name:
|
||||
result.name = contact.name
|
||||
|
||||
|
||||
def _trace_hash_for_key(public_key: str, hop_hash_bytes: int) -> str:
|
||||
return public_key[: hop_hash_bytes * 2].lower()
|
||||
|
||||
|
||||
def _trace_timeout_seconds(send_result: object) -> float:
|
||||
payload = getattr(send_result, "payload", None) or {}
|
||||
suggested_timeout = payload.get("suggested_timeout")
|
||||
try:
|
||||
if suggested_timeout is None:
|
||||
raise TypeError
|
||||
timeout_seconds = float(suggested_timeout) / 1000.0 * TRACE_TIMEOUT_MARGIN
|
||||
except (TypeError, ValueError):
|
||||
timeout_seconds = TRACE_DEFAULT_TIMEOUT_SECONDS
|
||||
return max(TRACE_TIMEOUT_MIN_SECONDS, min(TRACE_TIMEOUT_MAX_SECONDS, timeout_seconds))
|
||||
|
||||
|
||||
async def _resolve_trace_hops(
|
||||
hops: list[RadioTraceHopRequest], hop_hash_bytes: int
|
||||
) -> tuple[list[RadioTraceNode], list[str]]:
|
||||
trace_nodes: list[RadioTraceNode] = []
|
||||
requested_hashes: list[str] = []
|
||||
expected_hex_len = hop_hash_bytes * 2
|
||||
|
||||
for hop in hops:
|
||||
public_key = hop.public_key.strip().lower() if isinstance(hop.public_key, str) else None
|
||||
hop_hex = hop.hop_hex.strip().lower() if isinstance(hop.hop_hex, str) else None
|
||||
if bool(public_key) == bool(hop_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Each trace hop must provide exactly one of public_key or hop_hex",
|
||||
)
|
||||
|
||||
if public_key:
|
||||
if len(public_key) != 64:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Trace repeater keys must be full 64-character public keys",
|
||||
)
|
||||
try:
|
||||
bytes.fromhex(public_key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Trace repeater keys must be valid hex public keys",
|
||||
) from exc
|
||||
|
||||
contact = await ContactRepository.get_by_key(public_key)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Trace repeater not found: {public_key}"
|
||||
)
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Trace node is not a repeater: {public_key[:12]}",
|
||||
)
|
||||
requested_hashes.append(_trace_hash_for_key(contact.public_key, hop_hash_bytes))
|
||||
trace_nodes.append(
|
||||
RadioTraceNode(
|
||||
role="repeater",
|
||||
public_key=contact.public_key,
|
||||
name=contact.name,
|
||||
observed_hash=None,
|
||||
snr=None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
assert hop_hex is not None
|
||||
if len(hop_hex) != expected_hex_len:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Custom trace hops must be exactly {expected_hex_len} hex characters",
|
||||
)
|
||||
try:
|
||||
bytes.fromhex(hop_hex)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Custom trace hops must be valid hex",
|
||||
) from exc
|
||||
requested_hashes.append(hop_hex)
|
||||
trace_nodes.append(
|
||||
RadioTraceNode(
|
||||
role="custom",
|
||||
public_key=None,
|
||||
name=None,
|
||||
observed_hash=hop_hex,
|
||||
snr=None,
|
||||
)
|
||||
)
|
||||
|
||||
return trace_nodes, requested_hashes
|
||||
|
||||
|
||||
@router.get("/config", response_model=RadioConfigResponse)
|
||||
async def get_radio_config() -> RadioConfigResponse:
|
||||
"""Get the current radio configuration."""
|
||||
mc = require_connected()
|
||||
mc = radio_manager.require_connected()
|
||||
|
||||
info = mc.self_info
|
||||
if not info:
|
||||
raise HTTPException(status_code=503, detail="Radio info not available")
|
||||
|
||||
adv_loc_policy = info.get("adv_loc_policy", 1)
|
||||
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
|
||||
|
||||
return RadioConfigResponse(
|
||||
public_key=info.get("public_key", ""),
|
||||
name=info.get("name", ""),
|
||||
@@ -64,52 +356,31 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
sf=info.get("radio_sf", 0),
|
||||
cr=info.get("radio_cr", 0),
|
||||
),
|
||||
path_hash_mode=radio_manager.path_hash_mode,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
advert_location_source=advert_location_source,
|
||||
multi_acks_enabled=bool(info.get("multi_acks", 0)),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=RadioConfigResponse)
|
||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
"""Update radio configuration. Only provided fields will be updated."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
if update.lat is not None or update.lon is not None:
|
||||
current_info = mc.self_info
|
||||
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
|
||||
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
|
||||
logger.info("Setting radio coordinates to %f, %f", lat, lon)
|
||||
await mc.commands.set_coords(lat=lat, lon=lon)
|
||||
|
||||
if update.tx_power is not None:
|
||||
logger.info("Setting TX power to %d dBm", update.tx_power)
|
||||
await mc.commands.set_tx_power(val=update.tx_power)
|
||||
|
||||
if update.radio is not None:
|
||||
logger.info(
|
||||
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
|
||||
update.radio.freq,
|
||||
update.radio.bw,
|
||||
update.radio.sf,
|
||||
update.radio.cr,
|
||||
try:
|
||||
await apply_radio_config_update(
|
||||
mc,
|
||||
update,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
set_path_hash_mode=lambda mode: setattr(radio_manager, "path_hash_mode", mode),
|
||||
sync_radio_time_fn=sync_radio_time,
|
||||
)
|
||||
await mc.commands.set_radio(
|
||||
freq=update.radio.freq,
|
||||
bw=update.radio.bw,
|
||||
sf=update.radio.sf,
|
||||
cr=update.radio.cr,
|
||||
)
|
||||
|
||||
# Sync time with system clock
|
||||
await sync_radio_time(mc)
|
||||
|
||||
# Re-fetch self_info so the response reflects the changes we just made.
|
||||
# Commands like set_name() write to flash but don't update the cached
|
||||
# self_info — send_appstart() triggers a fresh SELF_INFO from the radio.
|
||||
await mc.commands.send_appstart()
|
||||
except PathHashModeUnsupportedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RadioCommandRejectedError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_radio_config()
|
||||
|
||||
@@ -117,7 +388,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
@router.put("/private-key")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(update.private_key)
|
||||
@@ -126,59 +397,214 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
|
||||
logger.info("Importing private key")
|
||||
async with radio_manager.radio_operation("import_private_key") as mc:
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to import private key: {result.payload}"
|
||||
)
|
||||
|
||||
# Re-export from radio so the server-side keystore uses the new key
|
||||
# for DM decryption immediately, rather than waiting for reconnect.
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
keystore_refreshed = await export_and_store_private_key(mc)
|
||||
if not keystore_refreshed:
|
||||
logger.warning("Keystore refresh failed after import, retrying once")
|
||||
keystore_refreshed = await export_and_store_private_key(mc)
|
||||
|
||||
if not keystore_refreshed:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Private key imported on radio, but server-side keystore "
|
||||
"refresh failed. Reconnect to apply the new key for DM decryption."
|
||||
),
|
||||
)
|
||||
try:
|
||||
await import_private_key_and_refresh_keystore(
|
||||
mc,
|
||||
key_bytes,
|
||||
export_and_store_private_key_fn=export_and_store_private_key,
|
||||
)
|
||||
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/advertise")
|
||||
async def send_advertisement() -> dict:
|
||||
"""Send a flood advertisement to announce presence on the mesh.
|
||||
async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> dict:
|
||||
"""Send an advertisement to announce presence on the mesh.
|
||||
|
||||
Manual advertisement requests always send immediately, updating the
|
||||
last_advert_time which affects when the next periodic/startup advert
|
||||
can occur.
|
||||
Manual advertisement requests always send immediately. Flood adverts update
|
||||
the shared flood-advert timing state used by periodic/startup advertising;
|
||||
zero-hop adverts currently do not.
|
||||
|
||||
Returns:
|
||||
status: "ok" if sent successfully
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
||||
|
||||
logger.info("Sending flood advertisement")
|
||||
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
||||
async with radio_manager.radio_operation("manual_advertisement") as mc:
|
||||
success = await do_send_advertisement(mc, force=True)
|
||||
success = await do_send_advertisement(mc, force=True, mode=mode)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to send advertisement")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
||||
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
||||
"""Run a short node-discovery sweep from the local radio."""
|
||||
radio_manager.require_connected()
|
||||
|
||||
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
tag_hex = tag.to_bytes(4, "little", signed=False).hex()
|
||||
events: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"discover_mesh",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
subscription = mc.subscribe(
|
||||
EventType.DISCOVER_RESPONSE,
|
||||
lambda event: events.put_nowait(event),
|
||||
{"tag": tag_hex},
|
||||
)
|
||||
try:
|
||||
send_result = await mc.commands.send_node_discover_req(
|
||||
target_bits,
|
||||
prefix_only=False,
|
||||
tag=tag,
|
||||
)
|
||||
if send_result is None or send_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail="Failed to start mesh discovery")
|
||||
|
||||
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
|
||||
results_by_key: dict[str, RadioDiscoveryResult] = {}
|
||||
|
||||
while True:
|
||||
remaining = deadline - _monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(events.get(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
merged = _merge_discovery_result(
|
||||
results_by_key.get(event.payload.get("pubkey")),
|
||||
event.payload,
|
||||
)
|
||||
if merged is not None:
|
||||
results_by_key[merged.public_key] = merged
|
||||
finally:
|
||||
subscription.unsubscribe()
|
||||
|
||||
results = sorted(
|
||||
results_by_key.values(),
|
||||
key=lambda item: (
|
||||
item.node_type,
|
||||
-(item.local_snr if item.local_snr is not None else -999.0),
|
||||
item.public_key,
|
||||
),
|
||||
)
|
||||
await _persist_new_discovery_contacts(results)
|
||||
await _attach_known_names(results)
|
||||
return RadioDiscoveryResponse(
|
||||
target=request.target,
|
||||
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/trace", response_model=RadioTraceResponse)
|
||||
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
||||
radio_manager.require_connected()
|
||||
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
trace_flags = TRACE_HASH_FLAGS[request.hop_hash_bytes]
|
||||
|
||||
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
|
||||
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
|
||||
if len(local_public_key) != 64:
|
||||
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
|
||||
local_name = (mc.self_info or {}).get("name")
|
||||
|
||||
response_task = asyncio.create_task(
|
||||
mc.wait_for_event(
|
||||
EventType.TRACE_DATA,
|
||||
attribute_filters={"tag": tag},
|
||||
timeout=TRACE_WAIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
try:
|
||||
send_result = await mc.commands.send_trace(
|
||||
path=",".join(requested_hashes),
|
||||
tag=tag,
|
||||
flags=trace_flags,
|
||||
)
|
||||
if send_result is None or send_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail="Failed to send trace")
|
||||
|
||||
timeout_seconds = _trace_timeout_seconds(send_result)
|
||||
try:
|
||||
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
||||
finally:
|
||||
if not response_task.done():
|
||||
response_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await response_task
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard")
|
||||
|
||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||
path_len = payload.get("path_len")
|
||||
if not isinstance(path_len, int):
|
||||
raise HTTPException(status_code=500, detail="Trace response was malformed")
|
||||
|
||||
raw_path = payload.get("path")
|
||||
path_nodes = raw_path if isinstance(raw_path, list) else []
|
||||
final_local_node = (
|
||||
path_nodes[-1]
|
||||
if path_nodes
|
||||
and isinstance(path_nodes[-1], dict)
|
||||
and not isinstance(path_nodes[-1].get("hash"), str)
|
||||
else None
|
||||
)
|
||||
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
||||
|
||||
if len(hashed_nodes) < len(trace_nodes):
|
||||
raise HTTPException(status_code=500, detail="Trace response was incomplete")
|
||||
|
||||
nodes: list[RadioTraceNode] = []
|
||||
for index, trace_node in enumerate(trace_nodes):
|
||||
observed = hashed_nodes[index] if index < len(hashed_nodes) else {}
|
||||
observed_hash = observed.get("hash") if isinstance(observed, dict) else None
|
||||
observed_snr = observed.get("snr") if isinstance(observed, dict) else None
|
||||
nodes.append(
|
||||
RadioTraceNode(
|
||||
role=trace_node.role,
|
||||
public_key=trace_node.public_key,
|
||||
name=trace_node.name,
|
||||
observed_hash=(
|
||||
observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash
|
||||
),
|
||||
snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None,
|
||||
)
|
||||
)
|
||||
|
||||
terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None
|
||||
nodes.append(
|
||||
RadioTraceNode(
|
||||
role="local",
|
||||
public_key=local_public_key,
|
||||
name=local_name if isinstance(local_name, str) and local_name else None,
|
||||
observed_hash=None,
|
||||
snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None,
|
||||
)
|
||||
)
|
||||
|
||||
return RadioTraceResponse(
|
||||
path_len=path_len,
|
||||
timeout_seconds=timeout_seconds,
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
|
||||
async def _attempt_reconnect() -> dict:
|
||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||
radio_manager.resume_connection()
|
||||
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
@@ -186,14 +612,8 @@ async def _attempt_reconnect() -> dict:
|
||||
"connected": False,
|
||||
}
|
||||
|
||||
success = await radio_manager.reconnect()
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
success = await _reconnect_and_prepare(broadcast_on_success=True)
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed after reconnect")
|
||||
raise HTTPException(
|
||||
@@ -201,9 +621,28 @@ async def _attempt_reconnect() -> dict:
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect_radio() -> dict:
|
||||
"""Disconnect from the radio and pause automatic reconnect attempts."""
|
||||
logger.info("Manual radio disconnect requested")
|
||||
await radio_manager.pause_connection()
|
||||
broadcast_health(False, radio_manager.connection_info)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Disconnected. Automatic reconnect is paused.",
|
||||
"connected": False,
|
||||
"paused": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reboot")
|
||||
async def reboot_radio() -> dict:
|
||||
"""Reboot the radio, or reconnect if not currently connected.
|
||||
@@ -238,8 +677,11 @@ async def reconnect_radio() -> dict:
|
||||
|
||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
if not await _prepare_connected(broadcast_on_success=True):
|
||||
raise HTTPException(status_code=503, detail="Radio connection is paused")
|
||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -6,8 +6,13 @@ import time
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import UnreadCounts
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ChannelRepository, ContactRepository, MessageRepository
|
||||
from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/read-state", tags=["read-state"])
|
||||
@@ -26,7 +31,12 @@ async def get_unreads() -> UnreadCounts:
|
||||
mc = radio_manager.meshcore
|
||||
if mc and mc.self_info:
|
||||
name = mc.self_info.get("name") or None
|
||||
data = await MessageRepository.get_unread_counts(name)
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
data = await MessageRepository.get_unread_counts(
|
||||
name, blocked_keys=blocked_keys, blocked_names=blocked_names
|
||||
)
|
||||
return UnreadCounts(**data)
|
||||
|
||||
|
||||
|
||||
+93
-244
@@ -1,12 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
AclEntry,
|
||||
@@ -21,16 +17,21 @@ from app.models import (
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterNodeInfoResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
from app.repository import ContactRepository, RepeaterTelemetryRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
from app.routers.server_control import (
|
||||
batch_cli_fetch,
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
send_contact_cli_command,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,124 +43,17 @@ ACL_PERMISSION_NAMES = {
|
||||
3: "Admin",
|
||||
}
|
||||
router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
|
||||
# Delay between repeater radio operations to allow key exchange and path establishment
|
||||
REPEATER_OP_DELAY_SECONDS = 2.0
|
||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability.
|
||||
|
||||
Patching time.monotonic directly breaks the asyncio event loop which also
|
||||
uses it. This indirection allows tests to control the clock safely.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
|
||||
|
||||
async def _fetch_repeater_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
|
||||
|
||||
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
|
||||
target repeater arrives or the wall-clock deadline expires. Unrelated messages
|
||||
are safe to skip — meshcore's event dispatcher already delivers them to the
|
||||
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
|
||||
timeout: Wall-clock seconds before giving up
|
||||
|
||||
Returns:
|
||||
The matching Event, or None if no response arrived before the deadline.
|
||||
"""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("get_msg() exception: %s", e)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
# No messages queued yet — wait and retry
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
# Not our target — already dispatched to subscribers by meshcore,
|
||||
# so just continue draining the queue.
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
# Already dispatched to subscribers by meshcore; skip.
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> None:
|
||||
"""Prepare connection to a repeater by adding to radio and logging in.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
contact: The repeater contact
|
||||
password: Password for login (empty string for no password)
|
||||
|
||||
Raises:
|
||||
HTTPException: If login fails
|
||||
"""
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
# Send login with password
|
||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=401, detail=f"Login failed: {login_result.payload}")
|
||||
|
||||
# Wait for key exchange to complete before sending requests
|
||||
logger.debug("Waiting %.1fs for key exchange to complete", REPEATER_OP_DELAY_SECONDS)
|
||||
await asyncio.sleep(REPEATER_OP_DELAY_SECONDS)
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
password,
|
||||
label="repeater",
|
||||
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _require_repeater(contact: Contact) -> None:
|
||||
@@ -179,8 +73,8 @@ def _require_repeater(contact: Contact) -> None:
|
||||
|
||||
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
||||
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Log in to a repeater. Adds contact to radio, sends login, waits for key exchange."""
|
||||
require_connected()
|
||||
"""Attempt repeater login and report whether auth was confirmed."""
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -189,15 +83,13 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await prepare_repeater_connection(mc, contact, request.password)
|
||||
|
||||
return RepeaterLoginResponse(status="ok")
|
||||
return await prepare_repeater_connection(mc, contact, request.password)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
||||
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -212,7 +104,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
response = RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
@@ -232,11 +124,47 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
# Record to telemetry history as a JSON blob (best-effort)
|
||||
now = int(time.time())
|
||||
status_dict = response.model_dump(exclude={"telemetry_history"})
|
||||
try:
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=now,
|
||||
data=status_dict,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to record telemetry history: %s", e)
|
||||
|
||||
# Fetch recent history and embed in response
|
||||
try:
|
||||
since = now - 30 * 86400 # last 30 days
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch telemetry history: %s", e)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{public_key}/repeater/telemetry-history",
|
||||
response_model=list[TelemetryHistoryEntry],
|
||||
)
|
||||
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
|
||||
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
since = int(time.time()) - 30 * 86400
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
return [TelemetryHistoryEntry(**row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -265,7 +193,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
|
||||
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
||||
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -299,7 +227,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
||||
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -334,49 +262,33 @@ async def _batch_cli_fetch(
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a repeater and collect responses.
|
||||
return await batch_cli_fetch(contact, operation_name, commands)
|
||||
|
||||
Opens a radio operation with polling paused and auto-fetch suspended (since
|
||||
we call get_msg() directly via _fetch_repeater_response), adds the contact
|
||||
to the radio for routing, then sends each command sequentially with a 1-second
|
||||
gap between them.
|
||||
|
||||
Returns a dict mapping field names to response strings (or None on timeout).
|
||||
"""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for i, (cmd, field) in enumerate(commands):
|
||||
if i > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await _fetch_repeater_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = _extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_node_info",
|
||||
[
|
||||
("get name", "name"),
|
||||
("get lat", "lat"),
|
||||
("get lon", "lon"),
|
||||
("clock", "clock_utc"),
|
||||
],
|
||||
)
|
||||
return RepeaterNodeInfoResponse(**results)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||
"""Fetch radio settings from a repeater via batch CLI commands."""
|
||||
require_connected()
|
||||
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -390,10 +302,6 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
("get af", "airtime_factor"),
|
||||
("get repeat", "repeat_enabled"),
|
||||
("get flood.max", "flood_max"),
|
||||
("get name", "name"),
|
||||
("get lat", "lat"),
|
||||
("get lon", "lon"),
|
||||
("clock", "clock_utc"),
|
||||
],
|
||||
)
|
||||
return RepeaterRadioSettingsResponse(**results)
|
||||
@@ -404,7 +312,7 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
)
|
||||
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -422,7 +330,7 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR
|
||||
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
"""Fetch owner info and guest password from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -439,72 +347,13 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater.
|
||||
"""Send a CLI command to a repeater or room server."""
|
||||
radio_manager.require_connected()
|
||||
|
||||
The contact must be a repeater (type=2). The user must have already logged in
|
||||
via the repeater/login endpoint. This endpoint ensures the contact is on the
|
||||
radio before sending commands (the repeater remembers ACL permissions after login).
|
||||
|
||||
Common commands:
|
||||
- get name, set name <value>
|
||||
- get tx, set tx <dbm>
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"send_repeater_command",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Send the command
|
||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
# Wait for response using validated fetch loop
|
||||
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from repeater %s for command: %s",
|
||||
contact.public_key[:12],
|
||||
request.command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
|
||||
response_text = _extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
||||
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
require_server_capable_contact(contact)
|
||||
return await send_contact_cli_command(
|
||||
contact,
|
||||
request.command,
|
||||
operation_name="send_repeater_command",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.models import (
|
||||
CONTACT_TYPE_ROOM,
|
||||
AclEntry,
|
||||
LppSensor,
|
||||
RepeaterAclResponse,
|
||||
RepeaterLoginRequest,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterStatusResponse,
|
||||
)
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["rooms"])
|
||||
|
||||
|
||||
def _require_room(contact) -> None:
|
||||
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt room-server login and report whether auth was confirmed."""
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_login",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
request.password,
|
||||
label="room server",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a room server."""
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_status", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from room server")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
last_rssi_dbm=status.get("last_rssi", 0),
|
||||
last_snr_db=status.get("last_snr", 0.0),
|
||||
packets_received=status.get("nb_recv", 0),
|
||||
packets_sent=status.get("nb_sent", 0),
|
||||
airtime_seconds=status.get("airtime", 0),
|
||||
rx_airtime_seconds=status.get("rx_airtime", 0),
|
||||
uptime_seconds=status.get("uptime", 0),
|
||||
sent_flood=status.get("sent_flood", 0),
|
||||
sent_direct=status.get("sent_direct", 0),
|
||||
recv_flood=status.get("recv_flood", 0),
|
||||
recv_direct=status.get("recv_direct", 0),
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from a room server."""
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
telemetry = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from room server")
|
||||
|
||||
sensors = [
|
||||
LppSensor(
|
||||
channel=entry.get("channel", 0),
|
||||
type_name=str(entry.get("type", "unknown")),
|
||||
value=entry.get("value", 0),
|
||||
)
|
||||
for entry in telemetry
|
||||
]
|
||||
return RepeaterLppTelemetryResponse(sensors=sensors)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL entries from a room server."""
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_acl", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
acl_entries = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.repeaters import ACL_PERMISSION_NAMES
|
||||
|
||||
for entry in acl_data:
|
||||
pubkey_prefix = entry.get("key", "")
|
||||
perm = entry.get("perm", 0)
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
acl_entries.append(
|
||||
AclEntry(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
permission=perm,
|
||||
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
|
||||
)
|
||||
)
|
||||
|
||||
return RepeaterAclResponse(acl=acl_entries)
|
||||
@@ -0,0 +1,327 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
RepeaterLoginResponse,
|
||||
)
|
||||
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
|
||||
from app.routers.contacts import _ensure_on_radio
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def get_server_contact_label(contact: Contact) -> str:
|
||||
"""Return a user-facing label for server-capable contacts."""
|
||||
if contact.type == CONTACT_TYPE_REPEATER:
|
||||
return "repeater"
|
||||
if contact.type == CONTACT_TYPE_ROOM:
|
||||
return "room server"
|
||||
return "server"
|
||||
|
||||
|
||||
def require_server_capable_contact(
|
||||
contact: Contact,
|
||||
*,
|
||||
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
|
||||
) -> None:
|
||||
"""Raise 400 if the contact does not support server control/login features."""
|
||||
if contact.type not in allowed_types:
|
||||
expected = ", ".join(str(value) for value in allowed_types)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
|
||||
)
|
||||
|
||||
|
||||
def _login_rejected_message(label: str) -> str:
|
||||
return (
|
||||
f"The {label} replied but did not confirm this login. "
|
||||
f"Existing access may still allow some {label} operations, but privileged actions may fail."
|
||||
)
|
||||
|
||||
|
||||
def _login_send_failed_message(label: str) -> str:
|
||||
return (
|
||||
f"The login request could not be sent to the {label}. "
|
||||
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
def _login_timeout_message(label: str) -> str:
|
||||
return (
|
||||
f"No login confirmation was heard from the {label}. "
|
||||
"That can mean the password was wrong or the reply was missed in transit. "
|
||||
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
def extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
|
||||
|
||||
async def fetch_contact_cli_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
await _store_pending_direct_message(result)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact: Contact,
|
||||
password: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
|
||||
pubkey_prefix = contact.public_key[:12].lower()
|
||||
contact_label = label or get_server_contact_label(contact)
|
||||
loop = asyncio.get_running_loop()
|
||||
login_future = loop.create_future()
|
||||
|
||||
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
||||
if login_future.done():
|
||||
return
|
||||
login_future.set_result(
|
||||
RepeaterLoginResponse(
|
||||
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
||||
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
success_subscription = mc.subscribe(
|
||||
EventType.LOGIN_SUCCESS,
|
||||
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
failed_subscription = mc.subscribe(
|
||||
EventType.LOGIN_FAILED,
|
||||
lambda _event: _resolve_login(
|
||||
EventType.LOGIN_FAILED,
|
||||
_login_rejected_message(contact_label),
|
||||
),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
login_future,
|
||||
timeout=response_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from %s %s within %.1fs",
|
||||
contact_label,
|
||||
contact.public_key[:12],
|
||||
response_timeout,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="timeout",
|
||||
authenticated=False,
|
||||
message=_login_timeout_message(contact_label),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
logger.warning(
|
||||
"%s login setup failed for %s: %s",
|
||||
contact_label.capitalize(),
|
||||
contact.public_key[:12],
|
||||
exc.detail,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
async def batch_cli_fetch(
|
||||
contact: Contact,
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a server-capable contact and collect responses.
|
||||
|
||||
Each command acquires and releases the radio lock independently so that
|
||||
other operations (sends, syncs) can slip in between commands.
|
||||
"""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
# Yield briefly so queued operations can acquire the lock.
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
# Re-ensure contact is loaded each iteration; another operation
|
||||
# may have evicted it while we didn't hold the lock.
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0) # settle after add_contact
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await fetch_contact_cli_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def send_contact_cli_command(
|
||||
contact: Contact,
|
||||
command: str,
|
||||
*,
|
||||
operation_name: str,
|
||||
) -> CommandResponse:
|
||||
"""Send a CLI command to a server-capable contact and return the text response."""
|
||||
label = get_server_contact_label(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from %s %s for command: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
response_text = extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info(
|
||||
"Received response from %s %s: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
response_text,
|
||||
)
|
||||
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
+158
-228
@@ -1,37 +1,18 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings, BotConfig
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def validate_bot_code(code: str, bot_name: str | None = None) -> None:
|
||||
"""Validate bot code syntax. Raises HTTPException on error."""
|
||||
if not code or not code.strip():
|
||||
return # Empty code is valid (disables bot)
|
||||
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
except SyntaxError as e:
|
||||
name_part = f"'{bot_name}' " if bot_name else ""
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot {name_part}has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
|
||||
def validate_all_bots(bots: list[BotConfig]) -> None:
|
||||
"""Validate all bots' code syntax. Raises HTTPException on first error."""
|
||||
for bot in bots:
|
||||
validate_bot_code(bot.code, bot.name)
|
||||
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
@@ -40,111 +21,74 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=1,
|
||||
le=1000,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
|
||||
"Configured radio contact capacity used for maintenance thresholds and "
|
||||
"background refill behavior"
|
||||
),
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
)
|
||||
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
|
||||
default=None,
|
||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
||||
)
|
||||
advert_interval: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
|
||||
)
|
||||
bots: list[BotConfig] | None = Field(
|
||||
flood_scope: str | None = Field(
|
||||
default=None,
|
||||
description="List of bot configurations",
|
||||
description="Outbound flood scope / region name (empty = disabled)",
|
||||
)
|
||||
mqtt_broker_host: str | None = Field(
|
||||
blocked_keys: list[str] | None = Field(
|
||||
default=None,
|
||||
description="MQTT broker hostname (empty = disabled)",
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
)
|
||||
mqtt_broker_port: int | None = Field(
|
||||
blocked_names: list[str] | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="MQTT broker port",
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
mqtt_username: str | None = Field(
|
||||
discovery_blocked_types: list[int] | None = Field(
|
||||
default=None,
|
||||
description="MQTT username (optional)",
|
||||
description=(
|
||||
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||
"advertisements should not create new contacts"
|
||||
),
|
||||
)
|
||||
mqtt_password: str | None = Field(
|
||||
auto_resend_channel: bool | None = Field(
|
||||
default=None,
|
||||
description="MQTT password (optional)",
|
||||
)
|
||||
mqtt_use_tls: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to use TLS for MQTT connection",
|
||||
)
|
||||
mqtt_tls_insecure: bool | None = Field(
|
||||
default=None,
|
||||
description="Skip TLS certificate verification (for self-signed certs)",
|
||||
)
|
||||
mqtt_topic_prefix: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT topic prefix",
|
||||
)
|
||||
mqtt_publish_messages: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish decrypted messages to MQTT",
|
||||
)
|
||||
mqtt_publish_raw_packets: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
community_mqtt_enabled: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish raw packets to the community MQTT broker",
|
||||
)
|
||||
community_mqtt_iata: str | None = Field(
|
||||
default=None,
|
||||
description="IATA region code for community MQTT topic routing (3 alpha chars)",
|
||||
)
|
||||
community_mqtt_broker_host: str | None = Field(
|
||||
default=None,
|
||||
description="Community MQTT broker hostname",
|
||||
)
|
||||
community_mqtt_broker_port: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="Community MQTT broker port",
|
||||
)
|
||||
community_mqtt_email: str | None = Field(
|
||||
default=None,
|
||||
description="Email address for node claiming on the community aggregator",
|
||||
description="Auto-resend channel messages once if no echo heard within 2 seconds",
|
||||
)
|
||||
|
||||
|
||||
class BlockKeyRequest(BaseModel):
|
||||
key: str = Field(description="Public key to toggle block status")
|
||||
|
||||
|
||||
class BlockNameRequest(BaseModel):
|
||||
name: str = Field(description="Display name to toggle block status")
|
||||
|
||||
|
||||
class FavoriteRequest(BaseModel):
|
||||
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class MigratePreferencesRequest(BaseModel):
|
||||
favorites: list[FavoriteRequest] = Field(
|
||||
default_factory=list,
|
||||
description="List of favorites from localStorage",
|
||||
)
|
||||
sort_order: str = Field(
|
||||
default="recent",
|
||||
description="Sort order preference from localStorage",
|
||||
)
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of conversation state keys to timestamps from localStorage",
|
||||
)
|
||||
class FavoriteToggleResponse(BaseModel):
|
||||
type: Literal["channel", "contact"]
|
||||
id: str
|
||||
favorite: bool
|
||||
|
||||
|
||||
class MigratePreferencesResponse(BaseModel):
|
||||
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
|
||||
settings: AppSettings = Field(description="Current settings after migration attempt")
|
||||
class TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
|
||||
class TrackedTelemetryResponse(BaseModel):
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
description="Current list of tracked repeater public keys"
|
||||
)
|
||||
names: dict[str, str] = Field(
|
||||
description="Map of public key to display name for tracked repeaters"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
@@ -168,10 +112,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
|
||||
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
|
||||
|
||||
if update.sidebar_sort_order is not None:
|
||||
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
|
||||
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
|
||||
|
||||
if update.advert_interval is not None:
|
||||
# Enforce minimum 1-hour interval; 0 means disabled
|
||||
interval = update.advert_interval
|
||||
@@ -180,149 +120,139 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating advert_interval to %d", interval)
|
||||
kwargs["advert_interval"] = interval
|
||||
|
||||
if update.bots is not None:
|
||||
validate_all_bots(update.bots)
|
||||
logger.info("Updating bots (count=%d)", len(update.bots))
|
||||
kwargs["bots"] = update.bots
|
||||
# Block lists
|
||||
if update.blocked_keys is not None:
|
||||
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
|
||||
if update.blocked_names is not None:
|
||||
kwargs["blocked_names"] = update.blocked_names
|
||||
|
||||
# MQTT fields
|
||||
mqtt_fields = [
|
||||
"mqtt_broker_host",
|
||||
"mqtt_broker_port",
|
||||
"mqtt_username",
|
||||
"mqtt_password",
|
||||
"mqtt_use_tls",
|
||||
"mqtt_tls_insecure",
|
||||
"mqtt_topic_prefix",
|
||||
"mqtt_publish_messages",
|
||||
"mqtt_publish_raw_packets",
|
||||
]
|
||||
mqtt_changed = False
|
||||
for field in mqtt_fields:
|
||||
value = getattr(update, field)
|
||||
if value is not None:
|
||||
kwargs[field] = value
|
||||
mqtt_changed = True
|
||||
# Discovery blocked types
|
||||
if update.discovery_blocked_types is not None:
|
||||
# Only allow valid contact type codes (1-4)
|
||||
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
|
||||
kwargs["discovery_blocked_types"] = sorted(set(valid))
|
||||
|
||||
# Community MQTT fields
|
||||
community_mqtt_changed = False
|
||||
if update.community_mqtt_enabled is not None:
|
||||
kwargs["community_mqtt_enabled"] = update.community_mqtt_enabled
|
||||
community_mqtt_changed = True
|
||||
# Auto-resend channel
|
||||
if update.auto_resend_channel is not None:
|
||||
kwargs["auto_resend_channel"] = update.auto_resend_channel
|
||||
|
||||
if update.community_mqtt_iata is not None:
|
||||
iata = update.community_mqtt_iata.upper().strip()
|
||||
if iata and not re.fullmatch(r"[A-Z]{3}", iata):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="IATA code must be exactly 3 uppercase alphabetic characters",
|
||||
)
|
||||
kwargs["community_mqtt_iata"] = iata
|
||||
community_mqtt_changed = True
|
||||
|
||||
if update.community_mqtt_broker_host is not None:
|
||||
kwargs["community_mqtt_broker_host"] = update.community_mqtt_broker_host
|
||||
community_mqtt_changed = True
|
||||
|
||||
if update.community_mqtt_broker_port is not None:
|
||||
kwargs["community_mqtt_broker_port"] = update.community_mqtt_broker_port
|
||||
community_mqtt_changed = True
|
||||
|
||||
if update.community_mqtt_email is not None:
|
||||
kwargs["community_mqtt_email"] = update.community_mqtt_email
|
||||
community_mqtt_changed = True
|
||||
|
||||
# Require IATA when enabling community MQTT
|
||||
if kwargs.get("community_mqtt_enabled", False):
|
||||
# Check the IATA value being set, or fall back to current settings
|
||||
iata_value = kwargs.get("community_mqtt_iata")
|
||||
if iata_value is None:
|
||||
current = await AppSettingsRepository.get()
|
||||
iata_value = current.community_mqtt_iata
|
||||
if not iata_value or not re.fullmatch(r"[A-Z]{3}", iata_value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A valid IATA region code is required to enable community sharing",
|
||||
)
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
kwargs["flood_scope"] = normalize_region_scope(update.flood_scope)
|
||||
flood_scope_changed = True
|
||||
|
||||
if kwargs:
|
||||
result = await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
# Restart MQTT publisher if any MQTT settings changed
|
||||
if mqtt_changed:
|
||||
from app.mqtt import mqtt_publisher
|
||||
# Apply flood scope to radio immediately if changed
|
||||
if flood_scope_changed:
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
await mqtt_publisher.restart(result)
|
||||
|
||||
# Restart community MQTT publisher if any community settings changed
|
||||
if community_mqtt_changed:
|
||||
from app.community_mqtt import community_publisher
|
||||
|
||||
await community_publisher.restart(result)
|
||||
if radio_manager.is_connected:
|
||||
try:
|
||||
scope = result.flood_scope
|
||||
async with radio_manager.radio_operation("set_flood_scope") as mc:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r to radio", scope or "(disabled)")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply flood_scope to radio: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
|
||||
@router.post("/favorites/toggle", response_model=AppSettings)
|
||||
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
@router.post("/favorites/toggle", response_model=FavoriteToggleResponse)
|
||||
async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
|
||||
"""Toggle a conversation's favorite status."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
|
||||
|
||||
if is_favorited:
|
||||
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
|
||||
else:
|
||||
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
|
||||
# When a contact favorite changes, sync the radio so the contact is
|
||||
# loaded/unloaded immediately rather than waiting for the next advert.
|
||||
if request.type == "contact":
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
contact = await ContactRepository.get_by_key(request.id)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
new_value = not contact.favorite
|
||||
await ContactRepository.set_favorite(request.id, new_value)
|
||||
logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12])
|
||||
# When newly favorited, load to radio immediately for DM ACK support
|
||||
if new_value:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
||||
"""Migrate all preferences from frontend localStorage to database.
|
||||
|
||||
This is a one-time migration. If preferences have already been migrated,
|
||||
this endpoint will not overwrite them and will return migrated=false.
|
||||
|
||||
Call this on frontend startup to ensure preferences are moved to the database.
|
||||
After successful migration, the frontend should clear localStorage preferences.
|
||||
|
||||
Migrates:
|
||||
- favorites (remoteterm-favorites)
|
||||
- sort_order (remoteterm-sortOrder)
|
||||
- last_message_times (remoteterm-lastMessageTime)
|
||||
"""
|
||||
# Convert to dict format for the repository method
|
||||
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
|
||||
|
||||
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
|
||||
favorites=frontend_favorites,
|
||||
sort_order=request.sort_order,
|
||||
last_message_times=request.last_message_times,
|
||||
)
|
||||
|
||||
if did_migrate:
|
||||
logger.info(
|
||||
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
|
||||
len(frontend_favorites),
|
||||
request.sort_order,
|
||||
len(request.last_message_times),
|
||||
)
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
else:
|
||||
logger.debug("Preferences already migrated, skipping")
|
||||
channel = await ChannelRepository.get_by_key(request.id)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
new_value = not channel.favorite
|
||||
await ChannelRepository.set_favorite(request.id, new_value)
|
||||
logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12])
|
||||
|
||||
return MigratePreferencesResponse(
|
||||
migrated=did_migrate,
|
||||
settings=settings,
|
||||
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
||||
|
||||
|
||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
||||
"""Toggle a public key's blocked status."""
|
||||
logger.info("Toggling blocked key: %s", request.key[:12])
|
||||
return await AppSettingsRepository.toggle_blocked_key(request.key)
|
||||
|
||||
|
||||
@router.post("/blocked-names/toggle", response_model=AppSettings)
|
||||
async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
|
||||
"""Toggle a display name's blocked status."""
|
||||
logger.info("Toggling blocked name: %s", request.name)
|
||||
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||
|
||||
|
||||
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
|
||||
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
|
||||
"""Toggle periodic telemetry collection for a repeater.
|
||||
|
||||
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
|
||||
the requested repeater is not already tracked.
|
||||
"""
|
||||
key = request.public_key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
current = settings.tracked_telemetry_repeaters
|
||||
|
||||
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||
names: dict[str, str] = {}
|
||||
for k in keys:
|
||||
contact = await ContactRepository.get_by_key(k)
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
logger.info("Removing repeater %s from tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
|
||||
# Validate it's a repeater
|
||||
contact = await ContactRepository.get_by_key(key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(status_code=400, detail="Contact is not a repeater")
|
||||
|
||||
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
|
||||
names = await _resolve_names(current)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
|
||||
"tracked_telemetry_repeaters": current,
|
||||
"names": names,
|
||||
},
|
||||
)
|
||||
|
||||
new_list = current + [key]
|
||||
logger.info("Adding repeater %s to tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.models import StatisticsResponse
|
||||
from app.repository import StatisticsRepository
|
||||
from app.services.radio_noise_floor import get_noise_floor_history
|
||||
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_statistics() -> StatisticsResponse:
|
||||
data = await StatisticsRepository.get_all()
|
||||
data["noise_floor_24h"] = await get_noise_floor_history()
|
||||
return StatisticsResponse(**data)
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.routers.health import build_health_data
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AUTH_REALM = "RemoteTerm"
|
||||
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
|
||||
|
||||
|
||||
class BasicAuthMiddleware:
|
||||
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
|
||||
|
||||
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
|
||||
self.app = app
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.realm = realm
|
||||
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
|
||||
|
||||
def _is_authorized(self, scope: dict[str, Any]) -> bool:
|
||||
headers = Headers(scope=scope)
|
||||
authorization = headers.get("authorization")
|
||||
if not authorization:
|
||||
return False
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if not token or scheme.lower() != "basic":
|
||||
return False
|
||||
|
||||
token = token.strip()
|
||||
try:
|
||||
decoded = base64.b64decode(token, validate=True).decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
logger.debug("Rejecting malformed basic auth header")
|
||||
return False
|
||||
|
||||
username, sep, password = decoded.partition(":")
|
||||
if not sep:
|
||||
return False
|
||||
|
||||
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
|
||||
password, self.password
|
||||
)
|
||||
|
||||
async def _send_http_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def _send_websocket_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
scope_type = scope["type"]
|
||||
if scope_type not in {"http", "websocket"}:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if self._is_authorized(scope):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if scope_type == "http":
|
||||
await self._send_http_unauthorized(send)
|
||||
return
|
||||
|
||||
await self._send_websocket_unauthorized(send)
|
||||
|
||||
|
||||
def add_optional_basic_auth_middleware(app, settings) -> None:
|
||||
"""Enable app-wide basic auth when configured via environment variables."""
|
||||
if not settings.basic_auth_enabled:
|
||||
return
|
||||
|
||||
app.add_middleware(
|
||||
BasicAuthMiddleware,
|
||||
username=settings.basic_auth_username,
|
||||
password=settings.basic_auth_password,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend service-layer helpers."""
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Shared contact/message reconciliation helpers."""
|
||||
|
||||
import logging
|
||||
|
||||
from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def promote_prefix_contacts_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts once a full key is known."""
|
||||
normalized_key = public_key.lower()
|
||||
promoted = await contact_repository.promote_prefix_placeholders(normalized_key)
|
||||
if promoted:
|
||||
(log or logger).info(
|
||||
"Promoted %d prefix contact placeholder(s) for %s",
|
||||
len(promoted),
|
||||
normalized_key[:12],
|
||||
)
|
||||
return promoted
|
||||
|
||||
|
||||
async def claim_prefix_messages_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> int:
|
||||
"""Promote prefix-key DMs to a resolved full public key."""
|
||||
normalized_key = public_key.lower()
|
||||
claimed = await message_repository.claim_prefix_messages(normalized_key)
|
||||
if claimed > 0:
|
||||
(log or logger).info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
normalized_key[:12],
|
||||
)
|
||||
return claimed
|
||||
|
||||
|
||||
async def backfill_channel_sender_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> int:
|
||||
"""Backfill channel sender attribution once a contact name is known."""
|
||||
if not contact_name:
|
||||
return 0
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
backfilled = await message_repository.backfill_channel_sender_key(
|
||||
normalized_key,
|
||||
contact_name,
|
||||
)
|
||||
if backfilled > 0:
|
||||
(log or logger).info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
contact_name,
|
||||
)
|
||||
return backfilled
|
||||
|
||||
|
||||
async def reconcile_contact_messages(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Apply message reconciliation once a contact's identity is resolved."""
|
||||
claimed = await claim_prefix_messages_for_contact(
|
||||
public_key=public_key,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
backfilled = await backfill_channel_sender_for_contact(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
return claimed, backfilled
|
||||
|
||||
|
||||
async def record_contact_name(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
timestamp: int,
|
||||
contact_name_history_repository=ContactNameHistoryRepository,
|
||||
) -> bool:
|
||||
"""Record contact name history when a non-empty name is available."""
|
||||
if not contact_name:
|
||||
return False
|
||||
|
||||
await contact_name_history_repository.record_name(
|
||||
public_key.lower(),
|
||||
contact_name,
|
||||
timestamp,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def record_contact_name_and_reconcile(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
timestamp: int,
|
||||
message_repository=MessageRepository,
|
||||
contact_name_history_repository=ContactNameHistoryRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Record name history, then reconcile message identity for the contact."""
|
||||
await record_contact_name(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
timestamp=timestamp,
|
||||
contact_name_history_repository=contact_name_history_repository,
|
||||
)
|
||||
return await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shared direct-message ACK application logic."""
|
||||
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import BroadcastFn, increment_ack_and_broadcast
|
||||
|
||||
|
||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||
"""Apply a DM ACK code using the shared pending/buffered state machine.
|
||||
|
||||
Returns True when the ACK matched a pending message, False when it was buffered.
|
||||
"""
|
||||
dm_ack_tracker.cleanup_expired_acks()
|
||||
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is None:
|
||||
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
||||
return False
|
||||
|
||||
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
|
||||
return True
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Shared pending ACK tracking for outgoing direct messages."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PendingAck = tuple[int, float, int]
|
||||
BUFFERED_ACK_TTL_SECONDS = 30.0
|
||||
|
||||
_pending_acks: dict[str, PendingAck] = {}
|
||||
_buffered_acks: dict[str, float] = {}
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||
"""Track an expected ACK code for an outgoing direct message.
|
||||
|
||||
Returns True when the ACK was already observed and buffered before registration.
|
||||
"""
|
||||
buffered_at = _buffered_acks.pop(expected_ack, None)
|
||||
if buffered_at is not None:
|
||||
logger.debug(
|
||||
"Matched buffered ACK %s immediately for message %d",
|
||||
expected_ack,
|
||||
message_id,
|
||||
)
|
||||
return True
|
||||
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug(
|
||||
"Tracking pending ACK %s for message %d (timeout %dms)",
|
||||
expected_ack,
|
||||
message_id,
|
||||
timeout_ms,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def buffer_unmatched_ack(ack_code: str) -> None:
|
||||
"""Remember an ACK that arrived before its message registration."""
|
||||
_buffered_acks[ack_code] = time.time()
|
||||
logger.debug("Buffered unmatched ACK %s for late registration", ack_code)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
"""Remove stale pending ACK entries."""
|
||||
now = time.time()
|
||||
expired_codes = [
|
||||
code
|
||||
for code, (_message_id, created_at, timeout_ms) in _pending_acks.items()
|
||||
if now - created_at > (timeout_ms / 1000) * 2
|
||||
]
|
||||
for code in expired_codes:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
|
||||
expired_buffered_codes = [
|
||||
code
|
||||
for code, buffered_at in _buffered_acks.items()
|
||||
if now - buffered_at > BUFFERED_ACK_TTL_SECONDS
|
||||
]
|
||||
for code in expired_buffered_codes:
|
||||
del _buffered_acks[code]
|
||||
logger.debug("Expired buffered ACK %s", code)
|
||||
|
||||
|
||||
def pop_pending_ack(ack_code: str) -> int | None:
|
||||
"""Claim the tracked message ID for an ACK code if present."""
|
||||
pending = _pending_acks.pop(ack_code, None)
|
||||
if pending is None:
|
||||
return None
|
||||
message_id, _, _ = pending
|
||||
return message_id
|
||||
|
||||
|
||||
def clear_pending_acks_for_message(message_id: int) -> None:
|
||||
"""Remove any still-pending ACK codes for a message once one ACK wins."""
|
||||
sibling_codes = [
|
||||
code
|
||||
for code, (pending_message_id, _created_at, _timeout_ms) in _pending_acks.items()
|
||||
if pending_message_id == message_id
|
||||
]
|
||||
for code in sibling_codes:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Cleared sibling pending ACK %s for message %d", code, message_id)
|
||||
@@ -0,0 +1,384 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
|
||||
from app.services.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_message_model,
|
||||
build_message_paths,
|
||||
format_contact_log_target,
|
||||
handle_duplicate_message,
|
||||
reconcile_duplicate_message,
|
||||
truncate_for_log,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_decrypted_dm_store_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FallbackDirectMessageContext:
|
||||
conversation_key: str
|
||||
contact: Contact | None
|
||||
sender_name: str | None
|
||||
sender_key: str | None
|
||||
skip_storage: bool = False
|
||||
|
||||
|
||||
async def _prepare_resolved_contact(
|
||||
contact: Contact,
|
||||
*,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[str, bool]:
|
||||
conversation_key = contact.public_key.lower()
|
||||
await claim_prefix_messages_for_contact(public_key=conversation_key, log=log or logger)
|
||||
|
||||
if contact.type == CONTACT_TYPE_REPEATER:
|
||||
return conversation_key, True
|
||||
|
||||
return conversation_key, False
|
||||
|
||||
|
||||
async def resolve_fallback_direct_message_context(
|
||||
*,
|
||||
sender_public_key: str,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> FallbackDirectMessageContext:
|
||||
normalized_sender = sender_public_key.lower()
|
||||
|
||||
try:
|
||||
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
(log or logger).warning(
|
||||
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
|
||||
sender_public_key,
|
||||
)
|
||||
contact = None
|
||||
|
||||
if contact is not None:
|
||||
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=log)
|
||||
return FallbackDirectMessageContext(
|
||||
conversation_key=conversation_key,
|
||||
contact=contact,
|
||||
sender_name=contact.name,
|
||||
sender_key=conversation_key,
|
||||
skip_storage=skip_storage,
|
||||
)
|
||||
|
||||
if normalized_sender:
|
||||
placeholder_upsert = ContactUpsert(
|
||||
public_key=normalized_sender,
|
||||
type=0,
|
||||
last_seen=received_at,
|
||||
last_contacted=received_at,
|
||||
first_seen=received_at,
|
||||
on_radio=False,
|
||||
)
|
||||
await contact_repository.upsert(placeholder_upsert)
|
||||
contact = await contact_repository.get_by_key(normalized_sender)
|
||||
if contact is not None:
|
||||
broadcast_fn("contact", contact.model_dump())
|
||||
|
||||
return FallbackDirectMessageContext(
|
||||
conversation_key=normalized_sender,
|
||||
contact=contact,
|
||||
sender_name=contact.name if contact else None,
|
||||
sender_key=normalized_sender or None,
|
||||
)
|
||||
|
||||
|
||||
async def resolve_direct_message_sender_metadata(
|
||||
*,
|
||||
sender_public_key: str,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve sender attribution for direct-message variants such as room-server posts."""
|
||||
normalized_sender = sender_public_key.lower()
|
||||
|
||||
try:
|
||||
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
(log or logger).warning(
|
||||
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
|
||||
sender_public_key,
|
||||
)
|
||||
contact = None
|
||||
|
||||
if contact is not None:
|
||||
await claim_prefix_messages_for_contact(
|
||||
public_key=contact.public_key.lower(), log=log or logger
|
||||
)
|
||||
return contact.name, contact.public_key.lower()
|
||||
|
||||
return None, normalized_sender or None
|
||||
|
||||
|
||||
async def _store_direct_message(
|
||||
*,
|
||||
packet_id: int | None,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
realtime: bool,
|
||||
broadcast_fn: BroadcastFn,
|
||||
update_last_contacted_key: str | None,
|
||||
best_effort_content_dedup: bool,
|
||||
linked_packet_dedup: bool,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
raw_packet_repository=RawPacketRepository,
|
||||
) -> Message | None:
|
||||
async def store() -> Message | None:
|
||||
if linked_packet_dedup and packet_id is not None:
|
||||
linked_message_id = await raw_packet_repository.get_linked_message_id(packet_id)
|
||||
if linked_message_id is not None:
|
||||
existing_msg = await message_repository.get_by_id(linked_message_id)
|
||||
if existing_msg is not None:
|
||||
await reconcile_duplicate_message(
|
||||
existing_msg=existing_msg,
|
||||
packet_id=packet_id,
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
if best_effort_content_dedup:
|
||||
existing_msg = await message_repository.get_by_content(
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
outgoing=outgoing,
|
||||
)
|
||||
if existing_msg is not None:
|
||||
await reconcile_duplicate_message(
|
||||
existing_msg=existing_msg,
|
||||
packet_id=packet_id,
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="PRIV",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
outgoing=outgoing,
|
||||
sender_key=sender_key,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=packet_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
outgoing=outgoing,
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
if packet_id is not None:
|
||||
await raw_packet_repository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr),
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
|
||||
|
||||
if update_last_contacted_key:
|
||||
await contact_repository.update_last_contacted(update_last_contacted_key, received_at)
|
||||
|
||||
return message
|
||||
|
||||
if linked_packet_dedup:
|
||||
async with _decrypted_dm_store_lock:
|
||||
return await store()
|
||||
return await store()
|
||||
|
||||
|
||||
async def ingest_decrypted_direct_message(
|
||||
*,
|
||||
packet_id: int,
|
||||
decrypted: "DecryptedDirectMessage",
|
||||
their_public_key: str,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
contact_repository=ContactRepository,
|
||||
) -> Message | None:
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
if not outgoing and decrypted.txt_type == 1:
|
||||
logger.debug(
|
||||
"Skipping CLI response from %s (txt_type=1): %s",
|
||||
conversation_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
contact = await contact_repository.get_by_key(conversation_key)
|
||||
sender_name: str | None = None
|
||||
sender_key: str | None = conversation_key if not outgoing else None
|
||||
signature: str | None = None
|
||||
if contact is not None:
|
||||
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
|
||||
if skip_storage:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (CLI responses not stored): %s",
|
||||
conversation_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
if not outgoing:
|
||||
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=decrypted.signed_sender_prefix,
|
||||
received_at=received_at or int(time.time()),
|
||||
broadcast_fn=broadcast_fn,
|
||||
contact_repository=contact_repository,
|
||||
log=logger,
|
||||
)
|
||||
signature = decrypted.signed_sender_prefix
|
||||
else:
|
||||
sender_name = contact.name
|
||||
|
||||
received = received_at or int(time.time())
|
||||
message = await _store_direct_message(
|
||||
packet_id=packet_id,
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
update_last_contacted_key=conversation_key,
|
||||
best_effort_content_dedup=outgoing,
|
||||
linked_packet_dedup=True,
|
||||
)
|
||||
if message is None:
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
|
||||
truncate_for_log(decrypted.message),
|
||||
format_contact_log_target(contact.name if contact else None, conversation_key),
|
||||
message.id,
|
||||
conversation_key,
|
||||
outgoing,
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
async def ingest_fallback_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
update_last_contacted_key: str | None = None,
|
||||
) -> Message | None:
|
||||
return await _store_direct_message(
|
||||
packet_id=None,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=False,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
realtime=True,
|
||||
broadcast_fn=broadcast_fn,
|
||||
update_last_contacted_key=update_last_contacted_key,
|
||||
best_effort_content_dedup=True,
|
||||
linked_packet_dedup=False,
|
||||
)
|
||||
@@ -0,0 +1,976 @@
|
||||
"""Shared send/resend orchestration for outgoing messages."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time as _time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import ResendChannelMessageResponse
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_stored_outgoing_channel_message,
|
||||
create_outgoing_channel_message,
|
||||
create_outgoing_direct_message,
|
||||
increment_ack_and_broadcast,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
|
||||
"Send command was issued to the radio, but no response was heard back. "
|
||||
"The message may or may not have sent successfully."
|
||||
)
|
||||
TrackAckFn = Callable[[str, int, int], bool]
|
||||
NowFn = Callable[[], float]
|
||||
OutgoingReservationKey = tuple[str, str, str]
|
||||
RetryTaskScheduler = Callable[[Any], Any]
|
||||
|
||||
# Channel echo watchdog: delay before checking for echoes
|
||||
ECHO_WATCHDOG_DELAY_SECONDS = 2.0
|
||||
|
||||
# Byte-perfect resend window (must match router's RESEND_WINDOW_SECONDS)
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
# Temp radio slot used by the router for channel sends
|
||||
WATCHDOG_TEMP_RADIO_SLOT = 0
|
||||
|
||||
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
|
||||
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
||||
|
||||
DM_SEND_MAX_ATTEMPTS = 3
|
||||
DEFAULT_DM_ACK_TIMEOUT_MS = 10000
|
||||
DM_RETRY_WAIT_MARGIN = 1.2
|
||||
|
||||
|
||||
async def allocate_outgoing_sender_timestamp(
|
||||
*,
|
||||
message_repository,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
requested_timestamp: int,
|
||||
) -> int:
|
||||
"""Pick a sender timestamp that will not collide with an existing stored message."""
|
||||
reservation_key = (msg_type, conversation_key, text)
|
||||
candidate = requested_timestamp
|
||||
while True:
|
||||
async with _outgoing_timestamp_reservations_lock:
|
||||
reserved = _pending_outgoing_timestamp_reservations.get(reservation_key, set())
|
||||
is_reserved = candidate in reserved
|
||||
|
||||
if is_reserved:
|
||||
candidate += 1
|
||||
continue
|
||||
|
||||
existing = await message_repository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=candidate,
|
||||
)
|
||||
if existing is not None:
|
||||
candidate += 1
|
||||
continue
|
||||
|
||||
async with _outgoing_timestamp_reservations_lock:
|
||||
reserved = _pending_outgoing_timestamp_reservations.setdefault(reservation_key, set())
|
||||
if candidate in reserved:
|
||||
candidate += 1
|
||||
continue
|
||||
reserved.add(candidate)
|
||||
break
|
||||
|
||||
if candidate != requested_timestamp:
|
||||
logger.info(
|
||||
"Bumped outgoing %s timestamp for %s from %d to %d to avoid same-content collision",
|
||||
msg_type,
|
||||
conversation_key[:12],
|
||||
requested_timestamp,
|
||||
candidate,
|
||||
)
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
async def release_outgoing_sender_timestamp(
|
||||
*,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
) -> None:
|
||||
reservation_key = (msg_type, conversation_key, text)
|
||||
async with _outgoing_timestamp_reservations_lock:
|
||||
reserved = _pending_outgoing_timestamp_reservations.get(reservation_key)
|
||||
if not reserved:
|
||||
return
|
||||
reserved.discard(sender_timestamp)
|
||||
if not reserved:
|
||||
_pending_outgoing_timestamp_reservations.pop(reservation_key, None)
|
||||
|
||||
|
||||
async def send_channel_message_with_effective_scope(
|
||||
*,
|
||||
mc,
|
||||
channel,
|
||||
channel_key: str,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
timestamp_bytes: bytes,
|
||||
action_label: str,
|
||||
radio_manager,
|
||||
temp_radio_slot: int,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope and/or path hash mode."""
|
||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||
baseline_scope = ""
|
||||
|
||||
if override_scope:
|
||||
settings = await app_settings_repository.get()
|
||||
baseline_scope = normalize_region_scope(settings.flood_scope)
|
||||
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
logger.info(
|
||||
"Temporarily applying channel flood_scope override for %s: %r",
|
||||
channel.name,
|
||||
override_scope,
|
||||
)
|
||||
override_result = await mc.commands.set_flood_scope(override_scope)
|
||||
if override_result is not None and override_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to apply channel flood_scope override for %s: %s",
|
||||
channel.name,
|
||||
override_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to apply regional override {override_scope!r} before {action_label}: "
|
||||
f"{override_result.payload}"
|
||||
),
|
||||
)
|
||||
|
||||
# Path hash mode per-channel override
|
||||
override_phm = channel.path_hash_mode_override
|
||||
baseline_phm = radio_manager.path_hash_mode
|
||||
apply_phm = (
|
||||
override_phm is not None
|
||||
and radio_manager.path_hash_mode_supported
|
||||
and override_phm != baseline_phm
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
logger.info(
|
||||
"Temporarily applying channel path_hash_mode override for %s: %d",
|
||||
channel.name,
|
||||
override_phm,
|
||||
)
|
||||
phm_result = await mc.commands.set_path_hash_mode(override_phm)
|
||||
if phm_result is not None and phm_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to apply channel path_hash_mode override for %s: %s",
|
||||
channel.name,
|
||||
phm_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to apply path hash mode override before {action_label}: "
|
||||
f"{phm_result.payload}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||
channel_key,
|
||||
preferred_slot=temp_radio_slot,
|
||||
)
|
||||
if needs_configure:
|
||||
logger.debug(
|
||||
"Loading channel %s into radio slot %d before %s%s",
|
||||
channel.name,
|
||||
channel_slot,
|
||||
action_label,
|
||||
(
|
||||
f" (evicting cached {evicted_channel_key[:8]})"
|
||||
if evicted_channel_key is not None
|
||||
else ""
|
||||
),
|
||||
)
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=channel_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
except Exception:
|
||||
if evicted_channel_key is not None:
|
||||
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
|
||||
raise
|
||||
if set_result.type == EventType.ERROR:
|
||||
if evicted_channel_key is not None:
|
||||
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
channel_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
)
|
||||
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
|
||||
else:
|
||||
logger.debug(
|
||||
"Reusing cached radio slot %d for channel %s before %s",
|
||||
channel_slot,
|
||||
channel.name,
|
||||
action_label,
|
||||
)
|
||||
|
||||
send_result = await mc.commands.send_chan_msg(
|
||||
chan=channel_slot,
|
||||
msg=text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if send_result is None:
|
||||
logger.warning(
|
||||
"No response from radio after %s for channel %s; send outcome is unknown",
|
||||
action_label,
|
||||
channel.name,
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
if send_result.type == EventType.ERROR:
|
||||
radio_manager.invalidate_cached_channel_slot(channel_key)
|
||||
else:
|
||||
radio_manager.note_channel_slot_used(channel_key)
|
||||
return send_result
|
||||
finally:
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
try:
|
||||
restore_result = await mc.commands.set_flood_scope(
|
||||
baseline_scope if baseline_scope else ""
|
||||
)
|
||||
if restore_result is not None and restore_result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to restore baseline flood_scope after sending to %s: %s",
|
||||
channel.name,
|
||||
restore_result.payload,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Restored baseline flood_scope after channel send: %r",
|
||||
baseline_scope or "(disabled)",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore baseline flood_scope after sending to %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
restored = False
|
||||
for attempt in range(3):
|
||||
try:
|
||||
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
|
||||
if restore_phm is not None and restore_phm.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Attempt %d/3: failed to restore path_hash_mode after sending to %s: %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
restore_phm.payload,
|
||||
)
|
||||
else:
|
||||
radio_manager.path_hash_mode = baseline_phm
|
||||
logger.debug(
|
||||
"Restored baseline path_hash_mode after channel send: %d",
|
||||
baseline_phm,
|
||||
)
|
||||
restored = True
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Attempt %d/3: exception restoring path_hash_mode after sending to %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
)
|
||||
if not restored:
|
||||
logger.error(
|
||||
"All 3 attempts to restore path_hash_mode failed for %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Path hash mode restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring path hash mode failed "
|
||||
f"after 3 attempts. The radio is still using a non-default hop "
|
||||
f"width. Set it back manually in Radio settings."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _extract_expected_ack_code(result: Any) -> str | None:
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
return None
|
||||
payload = result.payload or {}
|
||||
expected_ack = payload.get("expected_ack")
|
||||
if not expected_ack:
|
||||
return None
|
||||
return expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
|
||||
|
||||
def _get_ack_tracking_timeout_ms(result: Any) -> int:
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
return DEFAULT_DM_ACK_TIMEOUT_MS
|
||||
payload = result.payload or {}
|
||||
suggested_timeout = payload.get("suggested_timeout")
|
||||
if suggested_timeout is None:
|
||||
return DEFAULT_DM_ACK_TIMEOUT_MS
|
||||
try:
|
||||
return max(1, int(suggested_timeout))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_DM_ACK_TIMEOUT_MS
|
||||
|
||||
|
||||
def _get_direct_message_retry_timeout_ms(result: Any) -> int:
|
||||
"""Return the ACK window to wait before retrying a DM.
|
||||
|
||||
The MeshCore firmware already computes and returns `suggested_timeout` in
|
||||
`PACKET_MSG_SENT`, derived from estimated packet airtime and route mode.
|
||||
We use that firmware-supplied window directly so retries do not fire before
|
||||
the radio's own ACK timeout expires.
|
||||
|
||||
Sources:
|
||||
- https://github.com/meshcore-dev/MeshCore/blob/main/src/helpers/BaseChatMesh.cpp
|
||||
- https://github.com/meshcore-dev/MeshCore/blob/main/examples/companion_radio/MyMesh.cpp
|
||||
- https://github.com/meshcore-dev/MeshCore/blob/main/docs/companion_protocol.md
|
||||
"""
|
||||
return _get_ack_tracking_timeout_ms(result)
|
||||
|
||||
|
||||
async def _apply_direct_message_ack_tracking(
|
||||
*,
|
||||
result: Any,
|
||||
message_id: int,
|
||||
track_pending_ack_fn: TrackAckFn,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int:
|
||||
ack_code = _extract_expected_ack_code(result)
|
||||
if not ack_code:
|
||||
return 0
|
||||
|
||||
timeout_ms = _get_ack_tracking_timeout_ms(result)
|
||||
matched_immediately = track_pending_ack_fn(ack_code, message_id, timeout_ms) is True
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
|
||||
if matched_immediately:
|
||||
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
||||
return await increment_ack_and_broadcast(
|
||||
message_id=message_id,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
async def _is_message_acked(*, message_id: int, message_repository) -> bool:
|
||||
acked_count, _paths = await message_repository.get_ack_and_paths(message_id)
|
||||
return acked_count > 0
|
||||
|
||||
|
||||
async def _retry_direct_message_until_acked(
|
||||
*,
|
||||
contact,
|
||||
text: str,
|
||||
message_id: int,
|
||||
sender_timestamp: int,
|
||||
radio_manager,
|
||||
track_pending_ack_fn: TrackAckFn,
|
||||
broadcast_fn: BroadcastFn,
|
||||
wait_timeout_ms: int,
|
||||
sleep_fn,
|
||||
message_repository,
|
||||
) -> None:
|
||||
next_wait_timeout_ms = wait_timeout_ms
|
||||
for attempt in range(1, DM_SEND_MAX_ATTEMPTS):
|
||||
await sleep_fn((next_wait_timeout_ms / 1000) * DM_RETRY_WAIT_MARGIN)
|
||||
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
|
||||
return
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("retry_direct_message") as mc:
|
||||
contact_data = contact.to_radio_dict()
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to reload contact %s on radio before DM retry: %s",
|
||||
contact.public_key[:12],
|
||||
add_result.payload,
|
||||
)
|
||||
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not cached_contact:
|
||||
cached_contact = contact_data
|
||||
|
||||
if attempt == DM_SEND_MAX_ATTEMPTS - 1:
|
||||
reset_result = await mc.commands.reset_path(contact.public_key)
|
||||
if reset_result is None:
|
||||
logger.warning(
|
||||
"No response from radio for reset_path to %s before final DM retry",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif reset_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to reset path before final DM retry to %s: %s",
|
||||
contact.public_key[:12],
|
||||
reset_result.payload,
|
||||
)
|
||||
refreshed_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if refreshed_contact:
|
||||
cached_contact = refreshed_contact
|
||||
|
||||
result = await mc.commands.send_msg(
|
||||
dst=cached_contact,
|
||||
msg=text,
|
||||
timestamp=sender_timestamp,
|
||||
attempt=attempt,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Background DM retry attempt %d/%d failed for %s",
|
||||
attempt + 1,
|
||||
DM_SEND_MAX_ATTEMPTS,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
continue
|
||||
|
||||
if result is None:
|
||||
logger.warning(
|
||||
"No response from radio after background DM retry attempt %d/%d to %s",
|
||||
attempt + 1,
|
||||
DM_SEND_MAX_ATTEMPTS,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Background DM retry attempt %d/%d failed for %s: %s",
|
||||
attempt + 1,
|
||||
DM_SEND_MAX_ATTEMPTS,
|
||||
contact.public_key[:12],
|
||||
result.payload,
|
||||
)
|
||||
continue
|
||||
|
||||
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
|
||||
return
|
||||
|
||||
ack_code = _extract_expected_ack_code(result)
|
||||
if not ack_code:
|
||||
logger.warning(
|
||||
"Background DM retry attempt %d/%d for %s returned no expected_ack; "
|
||||
"stopping retries to avoid duplicate sends",
|
||||
attempt + 1,
|
||||
DM_SEND_MAX_ATTEMPTS,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
next_wait_timeout_ms = _get_direct_message_retry_timeout_ms(result)
|
||||
|
||||
ack_count = await _apply_direct_message_ack_tracking(
|
||||
result=result,
|
||||
message_id=message_id,
|
||||
track_pending_ack_fn=track_pending_ack_fn,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
if ack_count > 0:
|
||||
return
|
||||
|
||||
|
||||
async def send_direct_message_to_contact(
|
||||
*,
|
||||
contact,
|
||||
text: str,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
track_pending_ack_fn: TrackAckFn,
|
||||
now_fn: NowFn,
|
||||
retry_task_scheduler: RetryTaskScheduler | None = None,
|
||||
retry_sleep_fn=None,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
) -> Any:
|
||||
"""Send a direct message and persist/broadcast the outgoing row."""
|
||||
if retry_task_scheduler is None:
|
||||
retry_task_scheduler = asyncio.create_task
|
||||
if retry_sleep_fn is None:
|
||||
retry_sleep_fn = asyncio.sleep
|
||||
|
||||
contact_data = contact.to_radio_dict()
|
||||
sent_at: int | None = None
|
||||
sender_timestamp: int | None = None
|
||||
message = None
|
||||
result = None
|
||||
try:
|
||||
async with radio_manager.radio_operation("send_direct_message") as mc:
|
||||
logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12])
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
logger.warning("Failed to add contact to radio: %s", add_result.payload)
|
||||
|
||||
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not cached_contact:
|
||||
cached_contact = contact_data
|
||||
|
||||
logger.info("Sending direct message to %s", contact.public_key[:12])
|
||||
sent_at = int(now_fn())
|
||||
sender_timestamp = await allocate_outgoing_sender_timestamp(
|
||||
message_repository=message_repository,
|
||||
msg_type="PRIV",
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
result = await mc.commands.send_msg(
|
||||
dst=cached_contact,
|
||||
msg=text,
|
||||
timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
logger.warning(
|
||||
"No response from radio after direct send to %s; send outcome is unknown",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
message = await create_outgoing_direct_message(
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
finally:
|
||||
if sender_timestamp is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
msg_type="PRIV",
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if sent_at is None or sender_timestamp is None or message is None or result is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
|
||||
|
||||
ack_code = _extract_expected_ack_code(result)
|
||||
retry_timeout_ms = _get_direct_message_retry_timeout_ms(result)
|
||||
ack_count = await _apply_direct_message_ack_tracking(
|
||||
result=result,
|
||||
message_id=message.id,
|
||||
track_pending_ack_fn=track_pending_ack_fn,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
if ack_count > 0:
|
||||
message.acked = ack_count
|
||||
return message
|
||||
|
||||
if DM_SEND_MAX_ATTEMPTS > 1 and ack_code:
|
||||
retry_task_scheduler(
|
||||
_retry_direct_message_until_acked(
|
||||
contact=contact,
|
||||
text=text,
|
||||
message_id=message.id,
|
||||
sender_timestamp=sender_timestamp,
|
||||
radio_manager=radio_manager,
|
||||
track_pending_ack_fn=track_pending_ack_fn,
|
||||
broadcast_fn=broadcast_fn,
|
||||
wait_timeout_ms=retry_timeout_ms,
|
||||
sleep_fn=retry_sleep_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def _channel_echo_watchdog(
|
||||
message_id: int,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""One-shot watchdog: if no echo heard after delay, attempt one byte-perfect resend.
|
||||
|
||||
Spawned as a fire-and-forget task after a channel send when auto_resend_channel is enabled.
|
||||
Uses non-blocking radio lock so it never stalls user actions.
|
||||
"""
|
||||
try:
|
||||
await asyncio.sleep(ECHO_WATCHDOG_DELAY_SECONDS)
|
||||
|
||||
msg = await MessageRepository.get_by_id(message_id)
|
||||
if not msg:
|
||||
return
|
||||
if msg.acked > 0:
|
||||
logger.debug(
|
||||
"Echo watchdog: message %d already has %d echo(s), skipping", message_id, msg.acked
|
||||
)
|
||||
return
|
||||
if msg.sender_timestamp is None:
|
||||
return
|
||||
|
||||
elapsed = int(_time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
logger.debug(
|
||||
"Echo watchdog: message %d outside resend window (%ds)", message_id, elapsed
|
||||
)
|
||||
return
|
||||
|
||||
channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Echo watchdog: no echo for message %d after %.0fs, attempting byte-perfect resend",
|
||||
message_id,
|
||||
ECHO_WATCHDOG_DELAY_SECONDS,
|
||||
)
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
# Strip sender name prefix to get the raw text for the radio
|
||||
async with radio_manager.radio_operation("echo_watchdog_resend", blocking=False) as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_to_send = msg.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=msg.conversation_key,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="echo watchdog resend",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=WATCHDOG_TEMP_RADIO_SLOT,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result is not None and result.type != EventType.ERROR:
|
||||
logger.info("Echo watchdog: resent message %d successfully", message_id)
|
||||
else:
|
||||
logger.debug("Echo watchdog: resend got no/error result for message %d", message_id)
|
||||
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Echo watchdog: radio busy, skipping resend for message %d", message_id)
|
||||
except Exception:
|
||||
logger.debug("Echo watchdog: resend failed for message %d", message_id, exc_info=True)
|
||||
|
||||
|
||||
async def send_channel_message_to_channel(
|
||||
*,
|
||||
channel,
|
||||
channel_key_upper: str,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
now_fn: NowFn,
|
||||
temp_radio_slot: int,
|
||||
message_repository=MessageRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message and persist/broadcast the outgoing row."""
|
||||
sent_at: int | None = None
|
||||
sender_timestamp: int | None = None
|
||||
radio_name = ""
|
||||
our_public_key: str | None = None
|
||||
text_with_sender = text
|
||||
outgoing_message = None
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("send_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_with_sender = f"{radio_name}: {text}" if radio_name else text
|
||||
logger.info("Sending channel message to %s: %s", channel.name, text[:50])
|
||||
|
||||
sent_at = int(now_fn())
|
||||
sender_timestamp = await allocate_outgoing_sender_timestamp(
|
||||
message_repository=message_repository,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
||||
outgoing_message = await create_outgoing_channel_message(
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
broadcast=False,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if outgoing_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=channel_key_upper,
|
||||
key_bytes=key_bytes,
|
||||
text=text,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="sending message",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
logger.warning(
|
||||
"No response from radio after channel send to %s; send outcome is unknown",
|
||||
channel.name,
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send message: {result.payload}"
|
||||
)
|
||||
except Exception:
|
||||
if outgoing_message is not None:
|
||||
await message_repository.delete_by_id(outgoing_message.id)
|
||||
outgoing_message = None
|
||||
raise
|
||||
finally:
|
||||
if sender_timestamp is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
outgoing_message = await build_stored_outgoing_channel_message(
|
||||
message_id=outgoing_message.id,
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
||||
|
||||
# Spawn echo watchdog if auto-resend is enabled
|
||||
try:
|
||||
settings = await AppSettingsRepository.get()
|
||||
if settings.auto_resend_channel:
|
||||
asyncio.create_task(
|
||||
_channel_echo_watchdog(
|
||||
message_id=outgoing_message.id,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_fn,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass # Never let watchdog setup failure break the send
|
||||
|
||||
return outgoing_message
|
||||
|
||||
|
||||
async def resend_channel_message_record(
|
||||
*,
|
||||
message,
|
||||
channel,
|
||||
new_timestamp: bool,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
now_fn: NowFn,
|
||||
temp_radio_slot: int,
|
||||
message_repository=MessageRepository,
|
||||
) -> ResendChannelMessageResponse:
|
||||
"""Resend a stored outgoing channel message."""
|
||||
try:
|
||||
key_bytes = bytes.fromhex(message.conversation_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid channel key format: {message.conversation_key}",
|
||||
) from None
|
||||
|
||||
sent_at: int | None = None
|
||||
sender_timestamp = message.sender_timestamp
|
||||
timestamp_bytes = message.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
resend_public_key: str | None = None
|
||||
radio_name = ""
|
||||
new_message = None
|
||||
stored_text = message.text
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("resend_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_to_send = message.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
if new_timestamp:
|
||||
sent_at = int(now_fn())
|
||||
sender_timestamp = await allocate_outgoing_sender_timestamp(
|
||||
message_repository=message_repository,
|
||||
msg_type="CHAN",
|
||||
conversation_key=message.conversation_key,
|
||||
text=stored_text,
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
||||
new_message = await create_outgoing_channel_message(
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
broadcast=False,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if new_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store resent message - unexpected duplicate",
|
||||
)
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=message.conversation_key,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="resending message",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result is None:
|
||||
logger.warning(
|
||||
"No response from radio after channel resend to %s; send outcome is unknown",
|
||||
channel.name,
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to resend message: {result.payload}",
|
||||
)
|
||||
except Exception:
|
||||
if new_message is not None:
|
||||
await message_repository.delete_by_id(new_message.id)
|
||||
new_message = None
|
||||
raise
|
||||
finally:
|
||||
if new_timestamp and sent_at is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
msg_type="CHAN",
|
||||
conversation_key=message.conversation_key,
|
||||
text=stored_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if new_timestamp:
|
||||
if sent_at is None or new_message is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
|
||||
new_message = await build_stored_outgoing_channel_message(
|
||||
message_id=new_message.id,
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
broadcast_message(message=new_message, broadcast_fn=broadcast_fn)
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message.id,
|
||||
new_message.id,
|
||||
channel.name,
|
||||
)
|
||||
return ResendChannelMessageResponse(
|
||||
status="ok",
|
||||
message_id=new_message.id,
|
||||
message=new_message,
|
||||
)
|
||||
|
||||
logger.info("Resent channel message %d to %s", message.id, channel.name)
|
||||
return ResendChannelMessageResponse(status="ok", message_id=message.id)
|
||||
@@ -0,0 +1,525 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.models import Message, MessagePath
|
||||
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
LOG_MESSAGE_PREVIEW_LEN = 32
|
||||
|
||||
|
||||
def truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
|
||||
"""Return a compact single-line message preview for log output."""
|
||||
normalized = " ".join(text.split())
|
||||
if len(normalized) <= max_chars:
|
||||
return normalized
|
||||
return f"{normalized[:max_chars].rstrip()}..."
|
||||
|
||||
|
||||
def _format_channel_log_target(channel_name: str | None, channel_key: str) -> str:
|
||||
"""Return a human-friendly channel label for logs."""
|
||||
return channel_name or channel_key
|
||||
|
||||
|
||||
def format_contact_log_target(contact_name: str | None, public_key: str) -> str:
|
||||
"""Return a human-friendly DM target label for logs."""
|
||||
return contact_name or public_key[:12]
|
||||
|
||||
|
||||
def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath] | None:
|
||||
"""Build the single-path list used by message payloads."""
|
||||
return (
|
||||
[
|
||||
MessagePath(
|
||||
path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def build_message_model(
|
||||
*,
|
||||
message_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
received_at: int,
|
||||
paths: list[MessagePath] | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
sender_key: str | None = None,
|
||||
outgoing: bool = False,
|
||||
acked: int = 0,
|
||||
sender_name: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
packet_id: int | None = None,
|
||||
) -> Message:
|
||||
"""Build a Message model with the canonical backend payload shape."""
|
||||
return Message(
|
||||
id=message_id,
|
||||
type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
outgoing=outgoing,
|
||||
acked=acked,
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
|
||||
|
||||
def broadcast_message(
|
||||
*,
|
||||
message: Message,
|
||||
broadcast_fn: BroadcastFn,
|
||||
realtime: bool | None = None,
|
||||
) -> None:
|
||||
"""Broadcast a message payload, preserving the caller's broadcast signature."""
|
||||
payload = message.model_dump()
|
||||
if realtime is None:
|
||||
broadcast_fn("message", payload)
|
||||
else:
|
||||
broadcast_fn("message", payload, realtime=realtime)
|
||||
|
||||
|
||||
async def build_stored_outgoing_channel_message(
|
||||
*,
|
||||
message_id: int,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
channel_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message:
|
||||
"""Build the current payload for a stored outgoing channel message."""
|
||||
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
||||
return build_message_model(
|
||||
message_id=message_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
|
||||
|
||||
def broadcast_message_acked(
|
||||
*,
|
||||
message_id: int,
|
||||
ack_count: int,
|
||||
paths: list[MessagePath] | None,
|
||||
packet_id: int | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Broadcast a message_acked payload."""
|
||||
broadcast_fn(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [path.model_dump() for path in paths] if paths else [],
|
||||
"packet_id": packet_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def increment_ack_and_broadcast(
|
||||
*,
|
||||
message_id: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int:
|
||||
"""Increment a message's ACK count and broadcast the update."""
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
broadcast_fn("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
return ack_count
|
||||
|
||||
|
||||
async def reconcile_duplicate_message(
|
||||
*,
|
||||
existing_msg: Message,
|
||||
packet_id: int | None,
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
existing_msg.type,
|
||||
existing_msg.conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(
|
||||
existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
if existing_msg.outgoing and existing_msg.type == "CHAN":
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
representative_packet_id = (
|
||||
existing_msg.packet_id if existing_msg.packet_id is not None else packet_id
|
||||
)
|
||||
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_message_acked(
|
||||
message_id=existing_msg.id,
|
||||
ack_count=ack_count,
|
||||
paths=paths,
|
||||
packet_id=representative_packet_id,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
if packet_id is not None:
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def handle_duplicate_message(
|
||||
*,
|
||||
packet_id: int | None,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
outgoing: bool | None = None,
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
outgoing=outgoing,
|
||||
)
|
||||
if not existing_msg:
|
||||
label = "message" if msg_type == "CHAN" else "DM"
|
||||
logger.warning(
|
||||
"Duplicate %s for %s but couldn't find existing",
|
||||
label,
|
||||
conversation_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
await reconcile_duplicate_message(
|
||||
existing_msg=existing_msg,
|
||||
packet_id=packet_id,
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
*,
|
||||
packet_id: int,
|
||||
channel_key: str,
|
||||
sender: str | None,
|
||||
message_text: str,
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int | None:
|
||||
"""Store and broadcast a decrypted channel message."""
|
||||
received = received_at or int(time.time())
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
channel_key_normalized = channel_key.upper()
|
||||
|
||||
resolved_sender_key: str | None = None
|
||||
if sender:
|
||||
candidates = await ContactRepository.get_by_name(sender)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=channel_key_normalized,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=packet_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
outgoing=None,
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Stored channel message "%s" for %r (msg ID %d in chan ID %s)',
|
||||
truncate_for_log(text),
|
||||
_format_channel_log_target(channel_name, channel_key_normalized),
|
||||
msg_id,
|
||||
channel_key_normalized,
|
||||
)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
broadcast_message(
|
||||
message=build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr),
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
*,
|
||||
packet_id: int,
|
||||
decrypted: "DecryptedDirectMessage",
|
||||
their_public_key: str,
|
||||
our_public_key: str | None,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int | None:
|
||||
"""Store and broadcast a decrypted direct message."""
|
||||
from app.services.dm_ingest import ingest_decrypted_direct_message
|
||||
|
||||
message = await ingest_decrypted_direct_message(
|
||||
packet_id=packet_id,
|
||||
decrypted=decrypted,
|
||||
their_public_key=their_public_key,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return message.id if message is not None else None
|
||||
|
||||
|
||||
async def create_fallback_channel_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
message_text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
txt_type: int,
|
||||
sender_name: str | None,
|
||||
channel_name: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast a CHANNEL_MSG_RECV fallback channel message."""
|
||||
conversation_key_normalized = conversation_key.upper()
|
||||
text = f"{sender_name}: {message_text}" if sender_name else message_text
|
||||
|
||||
resolved_sender_key: str | None = None
|
||||
if sender_name:
|
||||
candidates = await ContactRepository.get_by_name(sender_name)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=conversation_key_normalized,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
sender_name=sender_name,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=None,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
outgoing=None,
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
txt_type=txt_type,
|
||||
sender_name=sender_name,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast an outgoing direct message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="PRIV",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_channel_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
channel_name: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
broadcast: bool = True,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast an outgoing channel message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = await build_stored_outgoing_channel_message(
|
||||
message_id=msg_id,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
channel_name=channel_name,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if broadcast:
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RadioCommandServiceError(RuntimeError):
|
||||
"""Base error for reusable radio command workflows."""
|
||||
|
||||
|
||||
class PathHashModeUnsupportedError(RadioCommandServiceError):
|
||||
"""Raised when firmware does not support path hash mode updates."""
|
||||
|
||||
|
||||
class RadioCommandRejectedError(RadioCommandServiceError):
|
||||
"""Raised when the radio reports an error for a command."""
|
||||
|
||||
|
||||
class KeystoreRefreshError(RadioCommandServiceError):
|
||||
"""Raised when server-side keystore refresh fails after import."""
|
||||
|
||||
|
||||
async def apply_radio_config_update(
|
||||
mc,
|
||||
update,
|
||||
*,
|
||||
path_hash_mode_supported: bool,
|
||||
set_path_hash_mode: Callable[[int], None],
|
||||
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
|
||||
) -> None:
|
||||
"""Apply a validated radio-config update to the connected radio."""
|
||||
if update.advert_location_source is not None:
|
||||
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
|
||||
logger.info(
|
||||
"Setting advert location policy to %s",
|
||||
update.advert_location_source,
|
||||
)
|
||||
result = await mc.commands.set_advert_loc_policy(advert_loc_policy)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(
|
||||
f"Failed to set advert location policy: {result.payload}"
|
||||
)
|
||||
|
||||
if update.multi_acks_enabled is not None:
|
||||
multi_acks = 1 if update.multi_acks_enabled else 0
|
||||
logger.info("Setting multi ACKs to %d", multi_acks)
|
||||
result = await mc.commands.set_multi_acks(multi_acks)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
if update.lat is not None or update.lon is not None:
|
||||
current_info = mc.self_info
|
||||
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
|
||||
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
|
||||
logger.info("Setting radio coordinates to %f, %f", lat, lon)
|
||||
await mc.commands.set_coords(lat=lat, lon=lon)
|
||||
|
||||
if update.tx_power is not None:
|
||||
logger.info("Setting TX power to %d dBm", update.tx_power)
|
||||
await mc.commands.set_tx_power(val=update.tx_power)
|
||||
|
||||
if update.radio is not None:
|
||||
logger.info(
|
||||
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
|
||||
update.radio.freq,
|
||||
update.radio.bw,
|
||||
update.radio.sf,
|
||||
update.radio.cr,
|
||||
)
|
||||
await mc.commands.set_radio(
|
||||
freq=update.radio.freq,
|
||||
bw=update.radio.bw,
|
||||
sf=update.radio.sf,
|
||||
cr=update.radio.cr,
|
||||
)
|
||||
|
||||
if update.path_hash_mode is not None:
|
||||
if not path_hash_mode_supported:
|
||||
raise PathHashModeUnsupportedError("Firmware does not support path hash mode setting")
|
||||
|
||||
logger.info("Setting path hash mode to %d", update.path_hash_mode)
|
||||
result = await mc.commands.set_path_hash_mode(update.path_hash_mode)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to set path hash mode: {result.payload}")
|
||||
set_path_hash_mode(update.path_hash_mode)
|
||||
|
||||
await sync_radio_time_fn(mc)
|
||||
|
||||
# Commands like set_name() write to flash but don't update cached self_info.
|
||||
# send_appstart() forces a fresh SELF_INFO so the response reflects changes.
|
||||
await mc.commands.send_appstart()
|
||||
|
||||
|
||||
async def import_private_key_and_refresh_keystore(
|
||||
mc,
|
||||
key_bytes: bytes,
|
||||
*,
|
||||
export_and_store_private_key_fn: Callable[[Any], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Import a private key and refresh the in-memory keystore immediately."""
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
if result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to import private key: {result.payload}")
|
||||
|
||||
keystore_refreshed = await export_and_store_private_key_fn(mc)
|
||||
if not keystore_refreshed:
|
||||
logger.warning("Keystore refresh failed after import, retrying once")
|
||||
keystore_refreshed = await export_and_store_private_key_fn(mc)
|
||||
|
||||
if not keystore_refreshed:
|
||||
raise KeystoreRefreshError(
|
||||
"Private key imported on radio, but server-side keystore refresh failed. "
|
||||
"Reconnect to apply the new key for DM decryption."
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
def _clean_device_string(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _decode_fixed_string(raw: bytes, start: int, length: int) -> str | None:
|
||||
if len(raw) < start:
|
||||
return None
|
||||
return _clean_device_string(
|
||||
raw[start : start + length].decode("utf-8", "ignore").replace("\0", "")
|
||||
)
|
||||
|
||||
|
||||
async def run_post_connect_setup(radio_manager) -> None:
|
||||
"""Run shared radio initialization after a transport connection succeeds."""
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
from app.radio_sync import (
|
||||
drain_pending_messages,
|
||||
send_advertisement,
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
start_telemetry_collect,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
|
||||
if not radio_manager.meshcore:
|
||||
return
|
||||
|
||||
if radio_manager._setup_lock is None:
|
||||
radio_manager._setup_lock = asyncio.Lock()
|
||||
|
||||
async def _setup_body() -> None:
|
||||
if not radio_manager.meshcore:
|
||||
return
|
||||
radio_manager._setup_in_progress = True
|
||||
radio_manager._setup_complete = False
|
||||
try:
|
||||
# Hold the operation lock for all radio I/O during setup.
|
||||
# This prevents user-initiated operations (send message, etc.)
|
||||
# from interleaving commands on the serial link.
|
||||
await radio_manager._acquire_operation_lock("post_connect_setup", blocking=True)
|
||||
try:
|
||||
mc = radio_manager.meshcore
|
||||
if not mc:
|
||||
return
|
||||
|
||||
# Register event handlers against the locked, current transport.
|
||||
register_event_handlers(mc)
|
||||
|
||||
await export_and_store_private_key(mc)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time(mc)
|
||||
|
||||
# Apply flood scope from settings (best-effort; older firmware
|
||||
# may not support set_flood_scope)
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
scope = normalize_region_scope(app_settings.flood_scope)
|
||||
try:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r", scope or "(disabled)")
|
||||
except Exception as exc:
|
||||
logger.warning("set_flood_scope failed (firmware may not support it): %s", exc)
|
||||
|
||||
# Query path hash mode support (best-effort; older firmware won't report it).
|
||||
# If the library's parsed payload is missing path_hash_mode (e.g. stale
|
||||
# .pyc on WSL2 Windows mounts), fall back to raw-frame extraction.
|
||||
reader = mc._reader
|
||||
_original_handle_rx = reader.handle_rx
|
||||
_captured_frame: list[bytes] = []
|
||||
|
||||
async def _capture_handle_rx(data: bytearray) -> None:
|
||||
from meshcore.packets import PacketType
|
||||
|
||||
if len(data) > 0 and data[0] == PacketType.DEVICE_INFO.value:
|
||||
_captured_frame.append(bytes(data))
|
||||
return await _original_handle_rx(data)
|
||||
|
||||
reader.handle_rx = _capture_handle_rx
|
||||
radio_manager.device_info_loaded = False
|
||||
radio_manager.max_contacts = None
|
||||
radio_manager.device_model = None
|
||||
radio_manager.firmware_build = None
|
||||
radio_manager.firmware_version = None
|
||||
radio_manager.max_channels = 40
|
||||
radio_manager.path_hash_mode = 0
|
||||
radio_manager.path_hash_mode_supported = False
|
||||
try:
|
||||
device_query = await mc.commands.send_device_query()
|
||||
payload = (
|
||||
device_query.payload
|
||||
if device_query is not None and isinstance(device_query.payload, dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
payload_max_contacts = payload.get("max_contacts")
|
||||
if isinstance(payload_max_contacts, int):
|
||||
radio_manager.max_contacts = max(1, payload_max_contacts)
|
||||
|
||||
payload_max_channels = payload.get("max_channels")
|
||||
if isinstance(payload_max_channels, int):
|
||||
radio_manager.max_channels = max(1, payload_max_channels)
|
||||
|
||||
radio_manager.device_model = _clean_device_string(payload.get("model"))
|
||||
radio_manager.firmware_build = _clean_device_string(payload.get("fw_build"))
|
||||
radio_manager.firmware_version = _clean_device_string(payload.get("ver"))
|
||||
|
||||
fw_ver = payload.get("fw ver")
|
||||
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
|
||||
if payload_reports_device_info:
|
||||
radio_manager.device_info_loaded = True
|
||||
|
||||
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
|
||||
radio_manager.path_hash_mode = payload["path_hash_mode"]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
|
||||
if _captured_frame:
|
||||
# Raw-frame fallback / completion:
|
||||
# byte 1 = fw_ver, byte 2 = max_contacts/2, byte 3 = max_channels,
|
||||
# bytes 8:20 = fw_build, 20:60 = model, 60:80 = ver, byte 81 = path_hash_mode
|
||||
raw = _captured_frame[-1]
|
||||
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||
if fw_ver >= 3:
|
||||
radio_manager.device_info_loaded = True
|
||||
if radio_manager.max_contacts is None and len(raw) >= 3:
|
||||
radio_manager.max_contacts = max(1, raw[2] * 2)
|
||||
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
|
||||
radio_manager.max_channels = max(1, raw[3])
|
||||
if radio_manager.firmware_build is None:
|
||||
radio_manager.firmware_build = _decode_fixed_string(raw, 8, 12)
|
||||
if radio_manager.device_model is None:
|
||||
radio_manager.device_model = _decode_fixed_string(raw, 20, 40)
|
||||
if radio_manager.firmware_version is None:
|
||||
radio_manager.firmware_version = _decode_fixed_string(raw, 60, 20)
|
||||
if (
|
||||
not radio_manager.path_hash_mode_supported
|
||||
and fw_ver >= 10
|
||||
and len(raw) >= 82
|
||||
):
|
||||
radio_manager.path_hash_mode = raw[81]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
logger.warning(
|
||||
"path_hash_mode=%d extracted from raw frame "
|
||||
"(stale .pyc? try: rm %s)",
|
||||
radio_manager.path_hash_mode,
|
||||
getattr(
|
||||
__import__("meshcore.reader", fromlist=["reader"]),
|
||||
"__cached__",
|
||||
"meshcore __pycache__/reader.*.pyc",
|
||||
),
|
||||
)
|
||||
if radio_manager.path_hash_mode_supported:
|
||||
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
|
||||
else:
|
||||
logger.debug("Firmware does not report path_hash_mode")
|
||||
if radio_manager.device_info_loaded:
|
||||
logger.info(
|
||||
"Radio device info: model=%s build=%s version=%s max_contacts=%s max_channels=%d",
|
||||
radio_manager.device_model or "unknown",
|
||||
radio_manager.firmware_build or "unknown",
|
||||
radio_manager.firmware_version or "unknown",
|
||||
radio_manager.max_contacts
|
||||
if radio_manager.max_contacts is not None
|
||||
else "unknown",
|
||||
radio_manager.max_channels,
|
||||
)
|
||||
try:
|
||||
time_result = await mc.commands.get_time()
|
||||
radio_time = (
|
||||
time_result.payload.get("time")
|
||||
if time_result is not None and time_result.payload
|
||||
else None
|
||||
)
|
||||
if isinstance(radio_time, int):
|
||||
logger.info(
|
||||
"Radio clock at connect: epoch=%d utc=%s",
|
||||
radio_time,
|
||||
datetime.fromtimestamp(radio_time, timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S UTC"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query radio clock after device info: %s", exc)
|
||||
logger.info("Max channel slots: %d", radio_manager.max_channels)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query device info capabilities: %s", exc)
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
from app.config import settings as app_settings_config
|
||||
|
||||
if app_settings_config.skip_post_connect_sync:
|
||||
logger.info(
|
||||
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
|
||||
)
|
||||
else:
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
c = result.get("contacts", {})
|
||||
ch = result.get("channels", {})
|
||||
logger.info(
|
||||
"Sync complete: %d contacts synced, %d channels synced, %d channels cleared",
|
||||
c.get("synced", 0),
|
||||
ch.get("synced", 0),
|
||||
ch.get("cleared", 0),
|
||||
)
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
radio_manager._release_operation_lock("post_connect_setup")
|
||||
|
||||
if not app_settings_config.skip_post_connect_sync:
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
start_telemetry_collect()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
finally:
|
||||
radio_manager._setup_in_progress = False
|
||||
|
||||
async with radio_manager._setup_lock:
|
||||
await asyncio.wait_for(_setup_body(), timeout=POST_CONNECT_SETUP_TIMEOUT_SECONDS)
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
|
||||
|
||||
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> bool:
|
||||
"""Finish setup for an already-connected radio and optionally broadcast health."""
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
if not radio_manager.connection_desired:
|
||||
if radio_manager.is_connected:
|
||||
await radio_manager.disconnect()
|
||||
return False
|
||||
|
||||
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
break
|
||||
except asyncio.TimeoutError as exc:
|
||||
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
|
||||
logger.warning(
|
||||
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
|
||||
attempt,
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
"Post-connect setup timed out after %ds on %d attempts. Initial radio offload "
|
||||
"took too long; something is probably wrong.",
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS,
|
||||
)
|
||||
broadcast_error(
|
||||
"Radio startup appears stuck",
|
||||
"Initial radio offload took too long. Reboot the radio and restart the server.",
|
||||
)
|
||||
raise RuntimeError("Post-connect setup timed out") from exc
|
||||
|
||||
if not radio_manager.connection_desired:
|
||||
if radio_manager.is_connected:
|
||||
await radio_manager.disconnect()
|
||||
return False
|
||||
|
||||
radio_manager._last_connected = True
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
return True
|
||||
|
||||
|
||||
async def reconnect_and_prepare_radio(
|
||||
radio_manager,
|
||||
*,
|
||||
broadcast_on_success: bool = True,
|
||||
) -> bool:
|
||||
"""Reconnect the transport, then run post-connect setup before reporting healthy."""
|
||||
connected = await radio_manager.reconnect(broadcast_on_success=False)
|
||||
if not connected:
|
||||
return False
|
||||
|
||||
return await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
|
||||
|
||||
|
||||
async def connection_monitor_loop(radio_manager) -> None:
|
||||
"""Monitor radio health and keep transport/setup state converged."""
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
check_interval_seconds = 5
|
||||
unresponsive_threshold = 3
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
current_connected = radio_manager.is_connected
|
||||
connection_desired = radio_manager.connection_desired
|
||||
|
||||
if radio_manager._last_connected and not current_connected:
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, radio_manager.connection_info)
|
||||
radio_manager._last_connected = False
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
if not connection_desired:
|
||||
if current_connected:
|
||||
logger.info("Radio connection paused by operator; disconnecting transport")
|
||||
await radio_manager.disconnect()
|
||||
consecutive_setup_failures = 0
|
||||
continue
|
||||
|
||||
if not current_connected:
|
||||
if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio(
|
||||
radio_manager,
|
||||
broadcast_on_success=True,
|
||||
):
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif not radio_manager._last_connected and current_connected:
|
||||
logger.info("Radio connection restored")
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif (
|
||||
current_connected
|
||||
and not radio_manager.is_setup_complete
|
||||
and not radio_manager.is_setup_in_progress
|
||||
):
|
||||
logger.info("Retrying post-connect setup...")
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
consecutive_setup_failures += 1
|
||||
if consecutive_setup_failures == unresponsive_threshold:
|
||||
logger.error(
|
||||
"Post-connect setup has failed %d times in a row. "
|
||||
"The radio port appears open but the radio is not "
|
||||
"responding to commands. Common causes: another "
|
||||
"process has the serial port open (check for other "
|
||||
"RemoteTerm instances, serial monitors, etc.), the "
|
||||
"firmware is in repeater mode (not client), or the "
|
||||
"radio needs a power cycle. Will keep retrying.",
|
||||
consecutive_setup_failures,
|
||||
)
|
||||
elif consecutive_setup_failures < unresponsive_threshold:
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""In-memory local-radio noise floor history sampling."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300
|
||||
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
|
||||
MAX_NOISE_FLOOR_SAMPLES = 300
|
||||
|
||||
_noise_floor_task: asyncio.Task | None = None
|
||||
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
|
||||
_noise_floor_supported: bool | None = None
|
||||
_samples_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None:
|
||||
async with _samples_lock:
|
||||
_noise_floor_samples.append((timestamp, noise_floor_dbm))
|
||||
|
||||
|
||||
async def sample_noise_floor_once(*, blocking: bool = False) -> None:
|
||||
"""Fetch the current radio noise floor once and record it when available."""
|
||||
global _noise_floor_supported
|
||||
|
||||
if not radio_manager.is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc:
|
||||
event = await mc.commands.get_stats_radio()
|
||||
except (RadioDisconnectedError, RadioOperationBusyError):
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug("Noise floor sampling failed: %s", exc)
|
||||
return
|
||||
|
||||
if event.type == EventType.ERROR:
|
||||
_noise_floor_supported = False
|
||||
return
|
||||
|
||||
if event.type != EventType.STATS_RADIO:
|
||||
return
|
||||
|
||||
noise_floor = event.payload.get("noise_floor")
|
||||
if not isinstance(noise_floor, int):
|
||||
return
|
||||
|
||||
_noise_floor_supported = True
|
||||
await _append_sample(int(time.time()), noise_floor)
|
||||
|
||||
|
||||
async def _noise_floor_sampling_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await sample_noise_floor_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Noise floor sampling loop crashed during sample")
|
||||
|
||||
try:
|
||||
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
|
||||
async def start_noise_floor_sampling() -> None:
|
||||
global _noise_floor_task
|
||||
if _noise_floor_task is not None and not _noise_floor_task.done():
|
||||
return
|
||||
_noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop())
|
||||
|
||||
|
||||
async def stop_noise_floor_sampling() -> None:
|
||||
global _noise_floor_task
|
||||
if _noise_floor_task is None:
|
||||
return
|
||||
if not _noise_floor_task.done():
|
||||
_noise_floor_task.cancel()
|
||||
try:
|
||||
await _noise_floor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_noise_floor_task = None
|
||||
|
||||
|
||||
async def get_noise_floor_history() -> dict:
|
||||
"""Return the current 24-hour in-memory noise floor history snapshot."""
|
||||
now = int(time.time())
|
||||
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
|
||||
|
||||
async with _samples_lock:
|
||||
samples = [
|
||||
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
|
||||
for timestamp, noise_floor_dbm in _noise_floor_samples
|
||||
if timestamp >= cutoff
|
||||
]
|
||||
|
||||
latest = samples[-1] if samples else None
|
||||
oldest_timestamp = samples[0]["timestamp"] if samples else None
|
||||
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
|
||||
|
||||
return {
|
||||
"sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS,
|
||||
"coverage_seconds": coverage_seconds,
|
||||
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
|
||||
"latest_timestamp": latest["timestamp"] if latest else None,
|
||||
"supported": _noise_floor_supported,
|
||||
"samples": samples,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Shared access seam over the global RadioManager instance.
|
||||
|
||||
This module deliberately keeps behavior thin and forwarding-only. The goal is
|
||||
to reduce direct `app.radio.radio_manager` imports across routers and helpers
|
||||
without changing radio lifecycle, lock, or connection semantics.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
import app.radio as radio_module
|
||||
|
||||
|
||||
class RadioRuntime:
|
||||
"""Thin forwarding wrapper around the process-global RadioManager."""
|
||||
|
||||
def __init__(self, manager_or_getter=None):
|
||||
if manager_or_getter is None:
|
||||
self._manager_getter: Callable[[], Any] = lambda: radio_module.radio_manager
|
||||
elif callable(manager_or_getter):
|
||||
self._manager_getter = manager_or_getter
|
||||
else:
|
||||
self._manager_getter = lambda: manager_or_getter
|
||||
|
||||
@property
|
||||
def manager(self) -> Any:
|
||||
return self._manager_getter()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Forward unknown attributes to the current global manager."""
|
||||
return getattr(self.manager, name)
|
||||
|
||||
@staticmethod
|
||||
def _is_local_runtime_attr(name: str) -> bool:
|
||||
return name.startswith("_") or hasattr(RadioRuntime, name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if self._is_local_runtime_attr(name):
|
||||
object.__setattr__(self, name, value)
|
||||
return
|
||||
setattr(self.manager, name, value)
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
if self._is_local_runtime_attr(name):
|
||||
object.__delattr__(self, name)
|
||||
return
|
||||
delattr(self.manager, name)
|
||||
|
||||
def require_connected(self):
|
||||
"""Return MeshCore when available, mirroring existing HTTP semantics."""
|
||||
if self.is_setup_in_progress:
|
||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
||||
if not self.is_connected:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
mc = self.meshcore
|
||||
if mc is None:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
return mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def radio_operation(self, name: str, **kwargs):
|
||||
async with self.manager.radio_operation(name, **kwargs) as mc:
|
||||
yield mc
|
||||
|
||||
async def start_connection_monitor(self) -> None:
|
||||
await self.manager.start_connection_monitor()
|
||||
|
||||
async def stop_connection_monitor(self) -> None:
|
||||
await self.manager.stop_connection_monitor()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
await self.manager.disconnect()
|
||||
|
||||
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
from app.services.radio_lifecycle import prepare_connected_radio
|
||||
|
||||
return await prepare_connected_radio(
|
||||
self.manager, broadcast_on_success=broadcast_on_success
|
||||
)
|
||||
|
||||
async def reconnect_and_prepare(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
from app.services.radio_lifecycle import reconnect_and_prepare_radio
|
||||
|
||||
return await reconnect_and_prepare_radio(
|
||||
self.manager,
|
||||
broadcast_on_success=broadcast_on_success,
|
||||
)
|
||||
|
||||
|
||||
radio_runtime = RadioRuntime()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Unified application version/build metadata resolution.
|
||||
|
||||
Resolution order:
|
||||
- version: installed package metadata, ``APP_VERSION`` env, ``build_info.json``, ``pyproject.toml``
|
||||
- commit: local git, ``COMMIT_HASH``/``VITE_COMMIT_HASH`` env, ``build_info.json``
|
||||
|
||||
This keeps backend surfaces, release bundles, and Docker builds aligned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomllib
|
||||
|
||||
RELEASE_BUILD_INFO_FILENAME = "build_info.json"
|
||||
PROJECT_NAME = "remoteterm-meshcore"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppBuildInfo:
|
||||
version: str
|
||||
version_source: str
|
||||
commit_hash: str | None
|
||||
commit_source: str | None
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read_build_info(root: Path) -> dict[str, Any] | None:
|
||||
build_info_path = root / RELEASE_BUILD_INFO_FILENAME
|
||||
try:
|
||||
data = json.loads(build_info_path.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _package_metadata_version() -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version(PROJECT_NAME)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _env_version() -> str | None:
|
||||
value = os.getenv("APP_VERSION")
|
||||
return value.strip() if value and value.strip() else None
|
||||
|
||||
|
||||
def _build_info_version(build_info: dict[str, Any] | None) -> str | None:
|
||||
if not build_info:
|
||||
return None
|
||||
value = build_info.get("version")
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _pyproject_version(root: Path) -> str | None:
|
||||
try:
|
||||
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
|
||||
project = pyproject.get("project")
|
||||
if isinstance(project, dict):
|
||||
version = project.get("version")
|
||||
if isinstance(version, str) and version.strip():
|
||||
return version.strip()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _git_output(root: Path, *args: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
return output or None
|
||||
|
||||
|
||||
def _env_commit_hash() -> str | None:
|
||||
for name in ("COMMIT_HASH", "VITE_COMMIT_HASH"):
|
||||
value = os.getenv(name)
|
||||
if value and value.strip():
|
||||
return value.strip()[:8]
|
||||
return None
|
||||
|
||||
|
||||
def _build_info_commit_hash(build_info: dict[str, Any] | None) -> str | None:
|
||||
if not build_info:
|
||||
return None
|
||||
value = build_info.get("commit_hash")
|
||||
return value.strip()[:8] if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_app_build_info() -> AppBuildInfo:
|
||||
root = repo_root()
|
||||
build_info = _read_build_info(root)
|
||||
|
||||
version = _package_metadata_version()
|
||||
version_source = "package_metadata"
|
||||
if version is None:
|
||||
version = _env_version()
|
||||
version_source = "env"
|
||||
if version is None:
|
||||
version = _build_info_version(build_info)
|
||||
version_source = "build_info"
|
||||
if version is None:
|
||||
version = _pyproject_version(root)
|
||||
version_source = "pyproject"
|
||||
if version is None:
|
||||
version = "0.0.0"
|
||||
version_source = "fallback"
|
||||
|
||||
commit_hash = _git_output(root, "rev-parse", "--short", "HEAD")
|
||||
commit_source: str | None = "git" if commit_hash else None
|
||||
if commit_hash is None:
|
||||
commit_hash = _env_commit_hash()
|
||||
commit_source = "env" if commit_hash else None
|
||||
if commit_hash is None:
|
||||
commit_hash = _build_info_commit_hash(build_info)
|
||||
commit_source = "build_info" if commit_hash else None
|
||||
|
||||
return AppBuildInfo(
|
||||
version=version,
|
||||
version_source=version_source,
|
||||
commit_hash=commit_hash,
|
||||
commit_source=commit_source,
|
||||
)
|
||||
|
||||
|
||||
def git_output(*args: str) -> str | None:
|
||||
"""Shared git helper for debug surfaces that still need live repo state."""
|
||||
return _git_output(repo_root(), *args)
|
||||
+17
-14
@@ -1,12 +1,13 @@
|
||||
"""WebSocket manager for real-time updates."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.events import dump_ws_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeout for individual WebSocket send operations (seconds)
|
||||
@@ -42,10 +43,7 @@ class WebSocketManager:
|
||||
3. Send to all clients concurrently with timeout
|
||||
4. Re-acquire lock to clean up disconnected clients
|
||||
"""
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
message = dump_ws_event(event_type, data)
|
||||
|
||||
# Copy connection list under lock to avoid holding lock during I/O
|
||||
async with self._lock:
|
||||
@@ -81,7 +79,7 @@ class WebSocketManager:
|
||||
|
||||
async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None:
|
||||
"""Send an event to a specific client."""
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
message = dump_ws_event(event_type, data)
|
||||
try:
|
||||
await websocket.send_text(message)
|
||||
except Exception as e:
|
||||
@@ -92,21 +90,26 @@ class WebSocketManager:
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
|
||||
def broadcast_event(event_type: str, data: dict) -> None:
|
||||
def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> None:
|
||||
"""Schedule a broadcast without blocking.
|
||||
|
||||
Convenience function that creates an asyncio task to broadcast
|
||||
an event to all connected WebSocket clients and forward to MQTT.
|
||||
an event to all connected WebSocket clients and forward to fanout modules.
|
||||
|
||||
Args:
|
||||
event_type: Event type string (e.g. "message", "raw_packet")
|
||||
data: Event payload dict
|
||||
realtime: If False, skip fanout dispatch (used for historical decryption)
|
||||
"""
|
||||
asyncio.create_task(ws_manager.broadcast(event_type, data))
|
||||
|
||||
from app.mqtt import mqtt_broadcast
|
||||
if realtime:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
mqtt_broadcast(event_type, data)
|
||||
|
||||
from app.community_mqtt import community_mqtt_broadcast
|
||||
|
||||
community_mqtt_broadcast(event_type, data)
|
||||
if event_type == "message":
|
||||
asyncio.create_task(fanout_manager.broadcast_message(data))
|
||||
elif event_type == "raw_packet":
|
||||
asyncio.create_task(fanout_manager.broadcast_raw(data))
|
||||
|
||||
|
||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
remoteterm:
|
||||
# build: .
|
||||
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||
|
||||
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
||||
# This is less reliable for serial-device access than running as root and may require
|
||||
# extra group setup (for example dialout) or other manual customization.
|
||||
# user: "${UID:-1000}:${GID:-1000}"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
||||
#####################################################################
|
||||
# Map your radio by stable device ID if available. #
|
||||
# If your by-id path contains ':' characters, Docker Compose cannot #
|
||||
# represent it here directly; use a colon-free host alias instead. #
|
||||
# (e.g. /dev/ttyUSB0) #
|
||||
#####################################################################
|
||||
devices:
|
||||
- /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio
|
||||
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
|
||||
# Radio connection
|
||||
# Serial (USB)
|
||||
MESHCORE_SERIAL_PORT: /dev/meshcore-radio
|
||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
|
||||
# BLE
|
||||
# BLE in Docker usually needs additional manual compose changes such as
|
||||
# Bluetooth device passthrough, privileged mode, host networking, or
|
||||
# other host-specific tweaks before it will actually work.
|
||||
# MESHCORE_BLE_ADDRESS: AA:BB:CC:DD:EE:FF
|
||||
# MESHCORE_BLE_PIN: 123456
|
||||
|
||||
# Security
|
||||
# MESHCORE_DISABLE_BOTS: "true"
|
||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||
|
||||
# Logging
|
||||
# MESHCORE_LOG_LEVEL: INFO
|
||||
restart: unless-stopped
|
||||
@@ -1,32 +0,0 @@
|
||||
services:
|
||||
remoteterm:
|
||||
build: .
|
||||
# image: jkingsman/remoteterm-meshcore:latest
|
||||
|
||||
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
||||
# user: "${UID:-1000}:${GID:-1000}"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
################################################
|
||||
# Set your serial device for passthrough here! #
|
||||
################################################
|
||||
devices:
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0
|
||||
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
|
||||
|
||||
# Serial (USB)
|
||||
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
|
||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
|
||||
# Logging
|
||||
# MESHCORE_LOG_LEVEL: INFO
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1 @@
|
||||
install-links=true
|
||||
+218
-35
@@ -12,32 +12,64 @@ Keep it aligned with `frontend/src` source code.
|
||||
- Tailwind utility classes + local CSS (`index.css`, `styles.css`)
|
||||
- Sonner (toasts)
|
||||
- Leaflet / react-leaflet (map)
|
||||
- `@michaelhart/meshcore-decoder` installed via npm alias to `meshcore-decoder-multibyte-patch`
|
||||
- `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker)
|
||||
- Multibyte-aware decoder build published as `meshcore-decoder-multibyte-patch`
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer fewer, stronger modules over many thin wrappers.
|
||||
- Split code only when the new hook/component owns a real invariant or workflow.
|
||||
- Keep one reasoning unit readable in one place, even if that file is moderately large.
|
||||
- Avoid dedicated files whose main job is pass-through, prop bundling, or renaming.
|
||||
- For this repo, "locally dense but semantically obvious" is better than indirection-heavy "clean architecture".
|
||||
- When refactoring, preserve behavior first and add tests around the seam being moved.
|
||||
|
||||
## Frontend Map
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── main.tsx # React entry point (StrictMode, root render)
|
||||
├── App.tsx # App shell and orchestration
|
||||
├── App.tsx # Data/orchestration entry that wires hooks into AppShell
|
||||
├── api.ts # Typed REST client
|
||||
├── types.ts # Shared TS contracts
|
||||
├── useWebSocket.ts # WS lifecycle + event dispatch
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── wsEvents.ts # Typed WS event parsing / discriminated union
|
||||
├── prefetch.ts # Consumes prefetched API promises started in index.html
|
||||
├── index.css # Global styles/utilities
|
||||
├── styles.css # Additional global app styles
|
||||
├── themes.css # Color theme definitions
|
||||
├── contexts/
|
||||
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
│ ├── index.ts # Central re-export of all hooks
|
||||
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
|
||||
│ ├── useConversationActions.ts # Send/resend/trace/block conversation actions
|
||||
│ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state
|
||||
│ ├── useConversationMessages.ts # Conversation timeline loading, cache restore, jump-target loading, pagination, dedup, pending ACK buffering
|
||||
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
|
||||
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
|
||||
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
|
||||
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
│ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
|
||||
│ ├── useFaviconBadge.ts # Browser tab unread badge state
|
||||
│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history
|
||||
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
|
||||
│ ├── visualizer/
|
||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||
│ │ ├── VisualizerControls.tsx # Visualizer legends and control panel overlay
|
||||
│ │ ├── VisualizerTooltip.tsx # Hover/pin node detail overlay
|
||||
│ │ └── shared.ts # Graph node/link types and shared rendering helpers
|
||||
│ └── ...
|
||||
├── utils/
|
||||
│ ├── urlHash.ts # Hash parsing and encoding
|
||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||
@@ -47,11 +79,19 @@ frontend/src/
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
|
||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
|
||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||
│ └── radioPresets.ts # LoRa radio preset configurations
|
||||
│ ├── radioPresets.ts # LoRa radio preset configurations
|
||||
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
|
||||
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
|
||||
│ └── theme.ts # Theme switching helpers
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
│ ├── Sidebar.tsx
|
||||
@@ -59,35 +99,49 @@ frontend/src/
|
||||
│ ├── MessageList.tsx
|
||||
│ ├── MessageInput.tsx
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SearchView.tsx # Full-text message search pane
|
||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
|
||||
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
|
||||
│ ├── MapView.tsx
|
||||
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
|
||||
│ ├── VisualizerView.tsx
|
||||
│ ├── PacketVisualizer3D.tsx
|
||||
│ ├── PathModal.tsx
|
||||
│ ├── PathRouteMap.tsx
|
||||
│ ├── CrackerPanel.tsx
|
||||
│ ├── BotCodeEditor.tsx
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||
│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog
|
||||
│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat
|
||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Preset, freq/bw/sf/cr, txPower, lat/lon
|
||||
│ │ ├── SettingsIdentitySection.tsx # Name, keys, advert interval
|
||||
│ │ ├── SettingsConnectivitySection.tsx # Connection status, max contacts, reboot
|
||||
│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ └── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ │ └── ThemeSelector.tsx # Color theme picker
|
||||
│ ├── repeater/
|
||||
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
|
||||
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
|
||||
│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
|
||||
│ │ ├── RepeaterAclPane.tsx # Permission table
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio settings + advert intervals
|
||||
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
|
||||
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
|
||||
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
|
||||
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
|
||||
@@ -95,58 +149,114 @@ frontend/src/
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── types/
|
||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
└── test/
|
||||
└── test/ # Representative frontend test suites (not an exhaustive listing)
|
||||
├── setup.ts
|
||||
├── fixtures/websocket_events.json
|
||||
├── api.test.ts
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── conversationPane.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── contactInfoPane.test.tsx
|
||||
├── integration.test.ts
|
||||
├── mapView.test.tsx
|
||||
├── messageCache.test.ts
|
||||
├── messageList.test.tsx
|
||||
├── messageParser.test.ts
|
||||
├── rawPacketList.test.tsx
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── rawPacketDetailModal.test.tsx
|
||||
├── rawPacketFeedView.test.tsx
|
||||
├── radioPresets.test.ts
|
||||
├── rawPacketIdentity.test.ts
|
||||
├── repeaterDashboard.test.tsx
|
||||
├── repeaterFormatters.test.ts
|
||||
├── repeaterLogin.test.tsx
|
||||
├── repeaterMessageParsing.test.ts
|
||||
├── roomServerPanel.test.tsx
|
||||
├── securityWarningModal.test.tsx
|
||||
├── localLabel.test.ts
|
||||
├── messageInput.test.tsx
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── statusBar.test.tsx
|
||||
├── tracePane.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
├── channelInfoKeyVisibility.test.tsx
|
||||
├── chatHeaderKeyVisibility.test.tsx
|
||||
├── searchView.test.tsx
|
||||
├── useConversationActions.test.ts
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
├── useAppShell.test.ts
|
||||
├── useBrowserNotifications.test.ts
|
||||
├── useFaviconBadge.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useRememberedServerPassword.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useRealtimeAppState.test.ts
|
||||
├── useUnreadCounts.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
└── useWebSocket.lifecycle.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
├── rawPacketStats.test.ts
|
||||
├── fontScale.test.ts
|
||||
└── wsEvents.test.ts
|
||||
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### State ownership
|
||||
|
||||
`App.tsx` orchestrates high-level state and delegates to hooks:
|
||||
`App.tsx` is now a thin composition entrypoint over the hook layer. `AppShell.tsx` owns shell layout/composition:
|
||||
- local label banner
|
||||
- status bar
|
||||
- desktop/mobile sidebar container
|
||||
- search/settings surface switching
|
||||
- global cracker mount/focus behavior
|
||||
- new-message modal and info panes
|
||||
- trusted-network `SecurityWarningModal`
|
||||
|
||||
High-level state is delegated to hooks:
|
||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||
- `useRadioControl`: radio health/config state, reconnect/reboot polling
|
||||
- `useAppSettings`: settings CRUD, favorites, preferences migration
|
||||
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
||||
- `useConversationRouter`: URL hash → active conversation routing
|
||||
- `useConversationMessages`: fetch, pagination, dedup/update helpers
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
|
||||
- `useConversationActions`: send/resend/trace/path-discovery/block handlers and channel override updates
|
||||
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, and server `last_read_ats` boundaries
|
||||
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
||||
|
||||
`App.tsx` intentionally still does the final `AppShell` prop assembly. That composition layer is considered acceptable here because it keeps the shell contract visible in one place and avoids a prop-bundling hook with little original logic.
|
||||
|
||||
`ConversationPane.tsx` owns the main active-conversation surface branching:
|
||||
- empty state
|
||||
- map view
|
||||
- visualizer
|
||||
- raw packet feed
|
||||
- trace view
|
||||
- repeater dashboard
|
||||
- room-server auth/status gate before room chat
|
||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||
|
||||
### Initial load + realtime
|
||||
|
||||
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
|
||||
- WebSocket: realtime deltas/events.
|
||||
- On reconnect, the app refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
|
||||
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
|
||||
|
||||
### New Message modal
|
||||
|
||||
`NewMessageModal` intentionally preserves form state (tab, inputs, checkboxes) when closed and reopened. The component instance persists across open/close cycles. This is by design so users don't lose in-progress input if they accidentally dismiss the dialog.
|
||||
`NewMessageModal` resets form state on close. The component instance persists across open/close cycles for smooth animations.
|
||||
|
||||
### Message behavior
|
||||
|
||||
@@ -154,22 +264,37 @@ frontend/src/
|
||||
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
|
||||
- ACK/repeat updates arrive as `message_acked` events.
|
||||
- Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`.
|
||||
- Conversation-scoped message caching now lives inside `useConversationMessages.ts` rather than a standalone `messageCache.ts` module. If you touch message timeline restore/dedup/reconnect behavior, start there.
|
||||
- `contact_resolved` is a real-time identity migration event, not just a contact-list update. Changes in that area need to consider active conversation state, cached messages, unread state keys, and reconnect reconciliation together.
|
||||
|
||||
### Visualizer behavior
|
||||
|
||||
- `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs).
|
||||
- `PacketVisualizer3D.tsx` is now a thin composition shell over visualizer-specific hooks/components in `components/visualizer/`.
|
||||
- `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame.
|
||||
- Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets.
|
||||
- Raw-packet decoding in `RawPacketList.tsx` and `visualizerUtils.ts` relies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backend `path_utils.py`.
|
||||
- Raw packet events carry both:
|
||||
- `id`: backend storage row identity (payload-level dedup)
|
||||
- `observation_id`: realtime per-arrival identity (session fidelity)
|
||||
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
|
||||
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
|
||||
|
||||
### Radio settings behavior
|
||||
|
||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||
- `SettingsRadioSection.tsx` also exposes `multi_acks_enabled` as a checkbox for the radio's extra direct-ACK transmission behavior.
|
||||
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
||||
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
|
||||
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
|
||||
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored).
|
||||
- Incoming JSON is parsed through `wsEvents.ts`, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes.
|
||||
- Event handlers: `health`, `message`, `contact`, `contact_resolved`, `channel`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
|
||||
## URL Hash Navigation (`utils/urlHash.ts`)
|
||||
@@ -179,12 +304,17 @@ Supported routes:
|
||||
- `#map`
|
||||
- `#map/focus/{pubkey_or_prefix}`
|
||||
- `#visualizer`
|
||||
- `#search`
|
||||
- `#trace`
|
||||
- `#settings/{section}`
|
||||
- `#channel/{channelKey}`
|
||||
- `#channel/{channelKey}/{label}`
|
||||
- `#contact/{publicKey}`
|
||||
- `#contact/{publicKey}/{label}`
|
||||
|
||||
Legacy name-based hashes are still accepted for compatibility.
|
||||
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||
|
||||
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||
|
||||
## Conversation State Keys (`utils/conversationState.ts`)
|
||||
|
||||
@@ -220,24 +350,27 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
|
||||
`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
|
||||
`HealthStatus` also includes `community_mqtt_status` with the same status values.
|
||||
|
||||
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
|
||||
|
||||
`HealthStatus` includes `fanout_statuses: Record<string, FanoutStatusEntry>` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`.
|
||||
|
||||
`FanoutConfig` represents a single fanout integration: `{id, type, name, enabled, config, scope, sort_order, created_at}`.
|
||||
|
||||
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
|
||||
|
||||
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, and `last_read_ats`. The unread-boundary/jump-to-unread behavior uses the server-provided `last_read_ats` map keyed by `getStateKey(...)`.
|
||||
|
||||
## Contact Info Pane
|
||||
|
||||
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/{key}/detail`:
|
||||
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:
|
||||
|
||||
- Header: avatar, name, public key, type badge, on-radio badge
|
||||
- Info grid: last seen, first heard, last contacted, distance, hops
|
||||
@@ -246,19 +379,32 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
- Name history ("Also Known As") — shown only when the contact has used multiple names
|
||||
- Message stats: DM count, channel message count
|
||||
- Most active rooms (clickable → navigate to channel)
|
||||
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
|
||||
- Advert observation rate
|
||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||
- Recent advert paths
|
||||
- Recent advert paths (informational only; not part of DM route selection)
|
||||
|
||||
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
|
||||
## Channel Info Pane
|
||||
|
||||
Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right drawer) showing channel details fetched from `GET /api/channels/{key}/detail`:
|
||||
|
||||
- Header: channel name, key (clickable copy), type badge (hashtag/private key), on-radio badge
|
||||
- Favorite toggle
|
||||
- Message activity: time-windowed counts (1h, 24h, 48h, 7d, all time) + unique senders
|
||||
- First message date
|
||||
- Top senders in last 24h (name + count)
|
||||
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot.
|
||||
|
||||
## Repeater Dashboard
|
||||
|
||||
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
||||
|
||||
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
|
||||
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
|
||||
|
||||
@@ -266,11 +412,40 @@ For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of
|
||||
|
||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||
|
||||
## Room Server Panel
|
||||
|
||||
For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required.
|
||||
|
||||
`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display.
|
||||
|
||||
## Message Search Pane
|
||||
|
||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||
|
||||
- **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation.
|
||||
- **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page.
|
||||
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
|
||||
- **Jump-to-message**: `useConversationMessages` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
|
||||
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
|
||||
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
|
||||
|
||||
## Styling
|
||||
|
||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
||||
Do not rely on old class-only layout assumptions.
|
||||
|
||||
### Canonical style reference
|
||||
|
||||
`SettingsLocalSection.tsx` contains a **ThemePreview** component with a collapsible "Canonical style reference" section. This is the authoritative catalog of text sizes, button variants, badge patterns, and interactive elements used throughout the app. **When adding or modifying UI, match the patterns shown there rather than inventing new ones.**
|
||||
|
||||
Key conventions documented in the reference:
|
||||
|
||||
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
|
||||
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
|
||||
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
|
||||
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
|
||||
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
- No authentication UI.
|
||||
@@ -279,10 +454,10 @@ Do not rely on old class-only layout assumptions.
|
||||
|
||||
## Testing
|
||||
|
||||
Run all quality checks (backend + frontend, parallelized) from the repo root:
|
||||
Run all quality checks (backend + frontend) from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
Or run frontend checks individually:
|
||||
@@ -293,6 +468,10 @@ npm run test:run
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run packaged-build` is release-only. It writes the fallback `frontend/prebuilt`
|
||||
directory used by the downloadable prebuilt release zip; normal development and
|
||||
validation should stick to `npm run build`.
|
||||
|
||||
When touching cross-layer contracts, also run backend tests from repo root:
|
||||
|
||||
```bash
|
||||
@@ -301,6 +480,10 @@ PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
### Contacts use mention styling for unread DMs
|
||||
|
||||
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions.
|
||||
|
||||
### RawPacketList always scrolls to bottom
|
||||
|
||||
`RawPacketList` unconditionally scrolls to the latest packet on every update. This is intentional — the packet feed is a live status display, not an interactive log meant for lingering or long-term analysis. Users watching it want to see the newest packet, not hold a scroll position.
|
||||
|
||||
+12
-12
@@ -9,11 +9,11 @@
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="./favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
|
||||
<link rel="manifest" href="./site.webmanifest" />
|
||||
<script>
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
@@ -42,17 +42,17 @@
|
||||
});
|
||||
};
|
||||
window.__prefetch = {
|
||||
config: fetchJsonOrThrow('/api/radio/config'),
|
||||
settings: fetchJsonOrThrow('/api/settings'),
|
||||
channels: fetchJsonOrThrow('/api/channels'),
|
||||
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
|
||||
config: fetchJsonOrThrow('./api/radio/config'),
|
||||
settings: fetchJsonOrThrow('./api/settings'),
|
||||
channels: fetchJsonOrThrow('./api/channels'),
|
||||
contacts: fetchJsonOrThrow('./api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('./api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('./api/packets/undecrypted/count'),
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Michael Hart <michaelhart@michaelhart.me> (https://github.com/michaelhart)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,453 @@
|
||||
# MeshCore Decoder
|
||||
|
||||
A TypeScript library for decoding MeshCore mesh networking packets with full cryptographic support. Uses WebAssembly (WASM) for Ed25519 key derivation through the [orlp/ed25519 library](https://github.com/orlp/ed25519).
|
||||
|
||||
This powers the [MeshCore Packet Analyzer](https://analyzer.letsme.sh/).
|
||||
|
||||
## Features
|
||||
|
||||
- **Packet Decoding**: Decode MeshCore packets
|
||||
- **Built-in Decryption**: Decrypt GroupText, TextMessage, and other encrypted payloads
|
||||
- **Developer Friendly**: TypeScript-first with full type safety and portability of JavaScript
|
||||
|
||||
## Installation
|
||||
|
||||
### Install to a single project
|
||||
|
||||
```bash
|
||||
npm install @michaelhart/meshcore-decoder
|
||||
```
|
||||
|
||||
### Install CLI (install globally)
|
||||
|
||||
```bash
|
||||
npm install -g @michaelhart/meshcore-decoder
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MeshCoreDecoder,
|
||||
PayloadType,
|
||||
Utils,
|
||||
DecodedPacket,
|
||||
AdvertPayload
|
||||
} from '@michaelhart/meshcore-decoder';
|
||||
|
||||
// Decode a MeshCore packet
|
||||
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';
|
||||
|
||||
const packet: DecodedPacket = MeshCoreDecoder.decode(hexData);
|
||||
|
||||
console.log(`Route Type: ${Utils.getRouteTypeName(packet.routeType)}`);
|
||||
console.log(`Payload Type: ${Utils.getPayloadTypeName(packet.payloadType)}`);
|
||||
console.log(`Message Hash: ${packet.messageHash}`);
|
||||
|
||||
if (packet.payloadType === PayloadType.Advert && packet.payload.decoded) {
|
||||
const advert: AdvertPayload = packet.payload.decoded as AdvertPayload;
|
||||
console.log(`Device Name: ${advert.appData.name}`);
|
||||
console.log(`Device Role: ${Utils.getDeviceRoleName(advert.appData.deviceRole)}`);
|
||||
if (advert.appData.location) {
|
||||
console.log(`Location: ${advert.appData.location.latitude}, ${advert.appData.location.longitude}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Full Packet Structure Example
|
||||
|
||||
Here's what a complete decoded packet looks like:
|
||||
|
||||
```typescript
|
||||
import { MeshCoreDecoder, DecodedPacket } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';
|
||||
|
||||
const packet: DecodedPacket = MeshCoreDecoder.decode(hexData);
|
||||
|
||||
console.log(JSON.stringify(packet, null, 2));
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"messageHash": "F9C060FE",
|
||||
"routeType": 1,
|
||||
"payloadType": 4,
|
||||
"payloadVersion": 0,
|
||||
"pathLength": 0,
|
||||
"path": null,
|
||||
"payload": {
|
||||
"raw": "7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172",
|
||||
"decoded": {
|
||||
"type": 4,
|
||||
"version": 0,
|
||||
"isValid": true,
|
||||
"publicKey": "7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400",
|
||||
"timestamp": 1758455660,
|
||||
"signature": "2E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E609",
|
||||
"appData": {
|
||||
"flags": 146,
|
||||
"deviceRole": 2,
|
||||
"hasLocation": true,
|
||||
"hasName": true,
|
||||
"location": {
|
||||
"latitude": 47.543968,
|
||||
"longitude": -122.108616
|
||||
},
|
||||
"name": "WW7STR/PugetMesh Cougar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"totalBytes": 134,
|
||||
"isValid": true
|
||||
}
|
||||
```
|
||||
|
||||
## Packet Support
|
||||
|
||||
| Value | Name | Description | Decoding | Decryption | Segment Analysis |
|
||||
|-------|------|-------------|----------|------------|------------------|
|
||||
| `0x00` | Request | Request (destination/source hashes + MAC) | ✅ | 🚧 | ✅ |
|
||||
| `0x01` | Response | Response to REQ or ANON_REQ | ✅ | 🚧 | ✅ |
|
||||
| `0x02` | Plain text message | Plain text message | ✅ | 🚧 | ✅ |
|
||||
| `0x03` | Acknowledgment | Acknowledgment | ✅ | N/A | ✅ |
|
||||
| `0x04` | Node advertisement | Node advertisement | ✅ | N/A | ✅ |
|
||||
| `0x05` | Group text message | Group text message | ✅ | ✅ | ✅ |
|
||||
| `0x06` | Group datagram | Group datagram | 🚧 | 🚧 | 🚧 |
|
||||
| `0x07` | Anonymous request | Anonymous request | ✅ | 🚧 | ✅ |
|
||||
| `0x08` | Returned path | Returned path | ✅ | N/A | ✅ |
|
||||
| `0x09` | Trace | Trace a path, collecting SNI for each hop | ✅ | N/A | ✅ |
|
||||
| `0x0A` | Multi-part packet | Packet is part of a sequence of packets | 🚧 | 🚧 | 🚧 |
|
||||
| `0x0F` | Custom packet | Custom packet (raw bytes, custom encryption) | 🚧 | 🚧 | 🚧 |
|
||||
|
||||
**Legend:**
|
||||
- ✅ Fully implemented
|
||||
- 🚧 Planned/In development
|
||||
- `-` Not applicable
|
||||
|
||||
For some packet types not yet supported here, they may not exist in MeshCore yet or I have yet to observe these packet types on the mesh.
|
||||
|
||||
## Decryption Support
|
||||
|
||||
Simply provide your channel secret keys and the library handles everything else:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MeshCoreDecoder,
|
||||
PayloadType,
|
||||
CryptoKeyStore,
|
||||
DecodedPacket,
|
||||
GroupTextPayload
|
||||
} from '@michaelhart/meshcore-decoder';
|
||||
|
||||
// Create a key store with channel secret keys
|
||||
const keyStore: CryptoKeyStore = MeshCoreDecoder.createKeyStore({
|
||||
channelSecrets: [
|
||||
'8b3387e9c5cdea6ac9e5edbaa115cd72', // Public channel (channel hash 11)
|
||||
'ff2b7d74e8d20f71505bda9ea8d59a1c', // A different channel's secret
|
||||
]
|
||||
});
|
||||
|
||||
const groupTextHexData: string = '...'; // Your encrypted GroupText packet hex
|
||||
|
||||
// Decode encrypted GroupText message
|
||||
const encryptedPacket: DecodedPacket = MeshCoreDecoder.decode(groupTextHexData, { keyStore });
|
||||
|
||||
if (encryptedPacket.payloadType === PayloadType.GroupText && encryptedPacket.payload.decoded) {
|
||||
const groupText: GroupTextPayload = encryptedPacket.payload.decoded as GroupTextPayload;
|
||||
|
||||
if (groupText.decrypted) {
|
||||
console.log(`Sender: ${groupText.decrypted.sender}`);
|
||||
console.log(`Message: ${groupText.decrypted.message}`);
|
||||
console.log(`Timestamp: ${new Date(groupText.decrypted.timestamp * 1000).toISOString()}`);
|
||||
} else {
|
||||
console.log('Message encrypted (no key available)');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The library automatically:
|
||||
- Calculates channel hashes from your secret keys using SHA256
|
||||
- Handles hash collisions (multiple keys with same first byte) by trying all matching keys
|
||||
- Verifies message authenticity using HMAC-SHA256
|
||||
- Decrypts using AES-128 ECB
|
||||
|
||||
## Packet Structure Analysis
|
||||
|
||||
For detailed packet analysis and debugging, use `analyzeStructure()` to get byte-level breakdowns:
|
||||
|
||||
```typescript
|
||||
import { MeshCoreDecoder, PacketStructure } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
console.log('=== Packet Breakdown ===');
|
||||
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';
|
||||
|
||||
console.log('Packet length:', hexData.length);
|
||||
console.log('Expected bytes:', hexData.length / 2);
|
||||
|
||||
const structure: PacketStructure = MeshCoreDecoder.analyzeStructure(hexData);
|
||||
console.log('\nMain segments:');
|
||||
structure.segments.forEach((seg, i) => {
|
||||
console.log(`${i+1}. ${seg.name} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
});
|
||||
|
||||
console.log('\nPayload segments:');
|
||||
structure.payload.segments.forEach((seg, i) => {
|
||||
console.log(`${i+1}. ${seg.name} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
console.log(` Description: ${seg.description}`);
|
||||
});
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
=== Packet Breakdown ===
|
||||
Packet length: 268
|
||||
Expected bytes: 134
|
||||
|
||||
Main segments:
|
||||
1. Header (bytes 0-0): 0x11
|
||||
2. Path Length (bytes 1-1): 0x00
|
||||
3. Payload (bytes 2-133): 7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
Payload segments:
|
||||
1. Public Key (bytes 0-31): 7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400
|
||||
Description: Ed25519 public key
|
||||
2. Timestamp (bytes 32-35): 6CE7CF68
|
||||
Description: 1758455660 (2025-09-21T11:54:20Z)
|
||||
3. Signature (bytes 36-99): 2E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E609
|
||||
Description: Ed25519 signature
|
||||
4. App Flags (bytes 100-100): 92
|
||||
Description: Binary: 10010010 | Bits 0-3 (Role): Room server | Bit 4 (Location): Yes | Bit 5 (Feature1): No | Bit 6 (Feature2): No | Bit 7 (Name): Yes
|
||||
5. Latitude (bytes 101-104): A076D502
|
||||
Description: 47.543968° (47.543968)
|
||||
6. Longitude (bytes 105-108): 38C5B8F8
|
||||
Description: -122.108616° (-122.108616)
|
||||
7. Node Name (bytes 109-131): 5757375354522F50756765744D65736820436F75676172
|
||||
Description: Node name: "WW7STR/PugetMesh Cougar"
|
||||
```
|
||||
|
||||
The `analyzeStructure()` method provides:
|
||||
- **Header breakdown** with bit-level field analysis
|
||||
- **Byte-accurate segments** with start/end positions
|
||||
- **Payload field parsing** for all supported packet types
|
||||
- **Human-readable descriptions** for each field
|
||||
|
||||
## Ed25519 Key Derivation
|
||||
|
||||
The library includes MeshCore-compatible Ed25519 key derivation using the exact orlp/ed25519 algorithm via WebAssembly:
|
||||
|
||||
```typescript
|
||||
import { Utils } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
// Derive public key from MeshCore private key (64-byte format)
|
||||
const privateKey = '18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5';
|
||||
|
||||
const publicKey = await Utils.derivePublicKey(privateKey);
|
||||
console.log('Derived Public Key:', publicKey);
|
||||
// Output: 4852B69364572B52EFA1B6BB3E6D0ABED4F389A1CBFBB60A9BBA2CCE649CAF0E
|
||||
|
||||
// Validate a key pair
|
||||
const isValid = await Utils.validateKeyPair(privateKey, publicKey);
|
||||
console.log('Key pair valid:', isValid); // true
|
||||
```
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
For quick analysis from the terminal, install globally and use the CLI:
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
npm install -g @michaelhart/meshcore-decoder
|
||||
|
||||
# Analyze a packet
|
||||
meshcore-decoder 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
# With decryption (provide channel secrets)
|
||||
meshcore-decoder 150011C3C1354D619BAE9590E4D177DB7EEAF982F5BDCF78005D75157D9535FA90178F785D --key 8b3387e9c5cdea6ac9e5edbaa115cd72
|
||||
|
||||
# Show detailed structure analysis
|
||||
meshcore-decoder --structure 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
# JSON output
|
||||
meshcore-decoder --json 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
# Derive public key from MeshCore private key
|
||||
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5
|
||||
|
||||
# Validate key pair
|
||||
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5 --validate 4852b69364572b52efa1b6bb3e6d0abed4f389a1cbfbb60a9bba2cce649caf0e
|
||||
|
||||
# Key derivation with JSON output
|
||||
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5 --json
|
||||
```
|
||||
|
||||
|
||||
## Using with Angular
|
||||
|
||||
The library works in Angular (and other browser-based) applications but requires additional configuration for WASM support and browser compatibility.
|
||||
|
||||
### 1. Configure Assets in `angular.json`
|
||||
|
||||
Add the WASM files to your Angular assets configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"your-app": {
|
||||
"architect": {
|
||||
"build": {
|
||||
"options": {
|
||||
"assets": [
|
||||
// ... your existing assets ...
|
||||
{
|
||||
"glob": "orlp-ed25519.*",
|
||||
"input": "./node_modules/@michaelhart/meshcore-decoder/lib",
|
||||
"output": "assets/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create a WASM Service
|
||||
|
||||
Create `src/app/services/meshcore-wasm.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MeshCoreWasmService {
|
||||
private wasm: any = null;
|
||||
public ready = new BehaviorSubject<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
this.loadWasm();
|
||||
}
|
||||
|
||||
private async loadWasm() {
|
||||
try {
|
||||
const jsResponse = await fetch('/assets/orlp-ed25519.js');
|
||||
const jsText = await jsResponse.text();
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.textContent = jsText;
|
||||
document.head.appendChild(script);
|
||||
|
||||
this.wasm = await (window as any).OrlpEd25519({
|
||||
locateFile: (path: string) => path === 'orlp-ed25519.wasm' ? '/assets/orlp-ed25519.wasm' : path
|
||||
});
|
||||
|
||||
this.ready.next(true);
|
||||
} catch (error) {
|
||||
console.error('WASM load failed:', error);
|
||||
this.ready.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
derivePublicKey(privateKeyHex: string): string | null {
|
||||
if (!this.wasm) return null;
|
||||
|
||||
const privateKeyBytes = this.hexToBytes(privateKeyHex);
|
||||
const privateKeyPtr = 1024;
|
||||
const publicKeyPtr = 1088;
|
||||
|
||||
this.wasm.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
|
||||
const result = this.wasm.ccall('orlp_derive_public_key', 'number', ['number', 'number'], [publicKeyPtr, privateKeyPtr]);
|
||||
|
||||
if (result === 0) {
|
||||
const publicKeyBytes = this.wasm.HEAPU8.subarray(publicKeyPtr, publicKeyPtr + 32);
|
||||
return this.bytesToHex(publicKeyBytes);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Basic Usage
|
||||
|
||||
```typescript
|
||||
import { MeshCorePacketDecoder } from '@michaelhart/meshcore-decoder';
|
||||
import { MeshCoreWasmService } from './services/meshcore-wasm';
|
||||
|
||||
// Basic packet decoding (works immediately)
|
||||
const packet = MeshCorePacketDecoder.decode(hexData);
|
||||
|
||||
// Key derivation (wait for WASM)
|
||||
wasmService.ready.subscribe(isReady => {
|
||||
if (isReady) {
|
||||
const publicKey = wasmService.derivePublicKey(privateKeyHex);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Angular/Browser: Important Notes
|
||||
|
||||
- **WASM Loading**: The library uses WebAssembly for Ed25519 key derivation. This requires proper asset configuration and a service to handle async WASM loading.
|
||||
- **Browser Compatibility**: The library automatically detects the environment and uses Web Crypto API in browsers, Node.js crypto in Node.js.
|
||||
- **Async Operations**: Key derivation is async due to WASM loading. Always wait for the `WasmService.ready` observable.
|
||||
- **Error Handling**: WASM operations may fail in some environments. Always wrap in try-catch blocks.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Development with ts-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Michael Hart <michaelhart@michaelhart.me> (https://github.com/michaelhart)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
//# sourceMappingURL=cli.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const packet_decoder_1 = require("./decoder/packet-decoder");
|
||||
const enums_1 = require("./types/enums");
|
||||
const enum_names_1 = require("./utils/enum-names");
|
||||
const index_1 = require("./index");
|
||||
const commander_1 = require("commander");
|
||||
const chalk_1 = __importDefault(require("chalk"));
|
||||
const packageJson = __importStar(require("../package.json"));
|
||||
commander_1.program
|
||||
.name('meshcore-decoder')
|
||||
.description('CLI tool for decoding MeshCore packets')
|
||||
.version(packageJson.version);
|
||||
// Default decode command
|
||||
commander_1.program
|
||||
.command('decode', { isDefault: true })
|
||||
.description('Decode a MeshCore packet')
|
||||
.argument('<hex>', 'Hex string of the packet to decode')
|
||||
.option('-k, --key <keys...>', 'Channel secret keys for decryption (hex)')
|
||||
.option('-j, --json', 'Output as JSON instead of formatted text')
|
||||
.option('-s, --structure', 'Show detailed packet structure analysis')
|
||||
.action(async (hex, options) => {
|
||||
try {
|
||||
// Clean up hex input
|
||||
const cleanHex = hex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
// Create key store if keys provided
|
||||
let keyStore;
|
||||
if (options.key && options.key.length > 0) {
|
||||
keyStore = packet_decoder_1.MeshCorePacketDecoder.createKeyStore({
|
||||
channelSecrets: options.key
|
||||
});
|
||||
}
|
||||
// Decode packet with signature verification
|
||||
const packet = await packet_decoder_1.MeshCorePacketDecoder.decodeWithVerification(cleanHex, { keyStore });
|
||||
if (options.json) {
|
||||
// JSON output
|
||||
if (options.structure) {
|
||||
const structure = await packet_decoder_1.MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { keyStore });
|
||||
console.log(JSON.stringify({ packet, structure }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(JSON.stringify(packet, null, 2));
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Formatted output
|
||||
console.log(chalk_1.default.cyan('=== MeshCore Packet Analysis ===\n'));
|
||||
if (!packet.isValid) {
|
||||
console.log(chalk_1.default.red('❌ Invalid Packet'));
|
||||
if (packet.errors) {
|
||||
packet.errors.forEach(error => console.log(chalk_1.default.red(` ${error}`)));
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.green('✅ Valid Packet'));
|
||||
}
|
||||
console.log(`${chalk_1.default.bold('Message Hash:')} ${packet.messageHash}`);
|
||||
console.log(`${chalk_1.default.bold('Route Type:')} ${(0, enum_names_1.getRouteTypeName)(packet.routeType)}`);
|
||||
console.log(`${chalk_1.default.bold('Payload Type:')} ${(0, enum_names_1.getPayloadTypeName)(packet.payloadType)}`);
|
||||
console.log(`${chalk_1.default.bold('Total Bytes:')} ${packet.totalBytes}`);
|
||||
if (packet.path && packet.path.length > 0) {
|
||||
console.log(`${chalk_1.default.bold('Path:')} ${packet.path.join(' → ')}`);
|
||||
}
|
||||
// Show payload details (even for invalid packets)
|
||||
if (packet.payload.decoded) {
|
||||
console.log(chalk_1.default.cyan('\n=== Payload Details ==='));
|
||||
showPayloadDetails(packet.payload.decoded);
|
||||
}
|
||||
// Exit with error code if packet is invalid
|
||||
if (!packet.isValid) {
|
||||
process.exit(1);
|
||||
}
|
||||
// Show structure if requested
|
||||
if (options.structure) {
|
||||
const structure = await packet_decoder_1.MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { keyStore });
|
||||
console.log(chalk_1.default.cyan('\n=== Packet Structure ==='));
|
||||
console.log(chalk_1.default.yellow('\nMain Segments:'));
|
||||
structure.segments.forEach((seg, i) => {
|
||||
console.log(`${i + 1}. ${chalk_1.default.bold(seg.name)} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
if (seg.description) {
|
||||
console.log(` ${chalk_1.default.dim(seg.description)}`);
|
||||
}
|
||||
});
|
||||
if (structure.payload.segments.length > 0) {
|
||||
console.log(chalk_1.default.yellow('\nPayload Segments:'));
|
||||
structure.payload.segments.forEach((seg, i) => {
|
||||
console.log(`${i + 1}. ${chalk_1.default.bold(seg.name)} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
console.log(` ${chalk_1.default.dim(seg.description)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(chalk_1.default.red('Error:'), error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
function showPayloadDetails(payload) {
|
||||
switch (payload.type) {
|
||||
case enums_1.PayloadType.Advert:
|
||||
const advert = payload;
|
||||
console.log(`${chalk_1.default.bold('Device Role:')} ${(0, enum_names_1.getDeviceRoleName)(advert.appData.deviceRole)}`);
|
||||
if (advert.appData.name) {
|
||||
console.log(`${chalk_1.default.bold('Device Name:')} ${advert.appData.name}`);
|
||||
}
|
||||
if (advert.appData.location) {
|
||||
console.log(`${chalk_1.default.bold('Location:')} ${advert.appData.location.latitude}, ${advert.appData.location.longitude}`);
|
||||
}
|
||||
console.log(`${chalk_1.default.bold('Timestamp:')} ${new Date(advert.timestamp * 1000).toISOString()}`);
|
||||
// Show signature verification status
|
||||
if (advert.signatureValid !== undefined) {
|
||||
if (advert.signatureValid) {
|
||||
console.log(`${chalk_1.default.bold('Signature:')} ${chalk_1.default.green('✅ Valid Ed25519 signature')}`);
|
||||
}
|
||||
else {
|
||||
console.log(`${chalk_1.default.bold('Signature:')} ${chalk_1.default.red('❌ Invalid Ed25519 signature')}`);
|
||||
if (advert.signatureError) {
|
||||
console.log(`${chalk_1.default.bold('Error:')} ${chalk_1.default.red(advert.signatureError)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log(`${chalk_1.default.bold('Signature:')} ${chalk_1.default.yellow('⚠️ Not verified (use async verification)')}`);
|
||||
}
|
||||
break;
|
||||
case enums_1.PayloadType.GroupText:
|
||||
const groupText = payload;
|
||||
console.log(`${chalk_1.default.bold('Channel Hash:')} ${groupText.channelHash}`);
|
||||
if (groupText.decrypted) {
|
||||
console.log(chalk_1.default.green('🔓 Decrypted Message:'));
|
||||
if (groupText.decrypted.sender) {
|
||||
console.log(`${chalk_1.default.bold('Sender:')} ${groupText.decrypted.sender}`);
|
||||
}
|
||||
console.log(`${chalk_1.default.bold('Message:')} ${groupText.decrypted.message}`);
|
||||
console.log(`${chalk_1.default.bold('Timestamp:')} ${new Date(groupText.decrypted.timestamp * 1000).toISOString()}`);
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.yellow('🔒 Encrypted (no key available)'));
|
||||
console.log(`${chalk_1.default.bold('Ciphertext:')} ${groupText.ciphertext.substring(0, 32)}...`);
|
||||
}
|
||||
break;
|
||||
case enums_1.PayloadType.Trace:
|
||||
const trace = payload;
|
||||
console.log(`${chalk_1.default.bold('Trace Tag:')} ${trace.traceTag}`);
|
||||
console.log(`${chalk_1.default.bold('Auth Code:')} ${trace.authCode}`);
|
||||
if (trace.snrValues && trace.snrValues.length > 0) {
|
||||
console.log(`${chalk_1.default.bold('SNR Values:')} ${trace.snrValues.map(snr => `${snr.toFixed(1)}dB`).join(', ')}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log(`${chalk_1.default.bold('Type:')} ${(0, enum_names_1.getPayloadTypeName)(payload.type)}`);
|
||||
console.log(`${chalk_1.default.bold('Valid:')} ${payload.isValid ? '✅' : '❌'}`);
|
||||
}
|
||||
}
|
||||
// Add key derivation command
|
||||
commander_1.program
|
||||
.command('derive-key')
|
||||
.description('Derive Ed25519 public key from MeshCore private key')
|
||||
.argument('<private-key>', '64-byte private key in hex format')
|
||||
.option('-v, --validate <public-key>', 'Validate against expected public key')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (privateKeyHex, options) => {
|
||||
try {
|
||||
// Clean up hex input
|
||||
const cleanPrivateKey = privateKeyHex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
if (cleanPrivateKey.length !== 128) {
|
||||
console.error(chalk_1.default.red('❌ Error: Private key must be exactly 64 bytes (128 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
if (options.json) {
|
||||
// JSON output
|
||||
const result = {
|
||||
privateKey: cleanPrivateKey,
|
||||
derivedPublicKey: await index_1.Utils.derivePublicKey(cleanPrivateKey)
|
||||
};
|
||||
if (options.validate) {
|
||||
const cleanExpectedKey = options.validate.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
result.expectedPublicKey = cleanExpectedKey;
|
||||
result.isValid = await index_1.Utils.validateKeyPair(cleanPrivateKey, cleanExpectedKey);
|
||||
result.match = result.derivedPublicKey.toLowerCase() === cleanExpectedKey.toLowerCase();
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
else {
|
||||
// Formatted output
|
||||
console.log(chalk_1.default.cyan('=== MeshCore Ed25519 Key Derivation ===\n'));
|
||||
console.log(chalk_1.default.bold('Private Key (64 bytes):'));
|
||||
console.log(chalk_1.default.gray(cleanPrivateKey));
|
||||
console.log();
|
||||
console.log(chalk_1.default.bold('Derived Public Key (32 bytes):'));
|
||||
const derivedKey = await index_1.Utils.derivePublicKey(cleanPrivateKey);
|
||||
console.log(chalk_1.default.green(derivedKey));
|
||||
console.log();
|
||||
if (options.validate) {
|
||||
const cleanExpectedKey = options.validate.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
console.log(chalk_1.default.bold('Expected Public Key:'));
|
||||
console.log(chalk_1.default.gray(cleanExpectedKey));
|
||||
console.log();
|
||||
const match = derivedKey.toLowerCase() === cleanExpectedKey.toLowerCase();
|
||||
console.log(chalk_1.default.bold('Validation:'));
|
||||
console.log(match ? chalk_1.default.green('Keys match') : chalk_1.default.red('Keys do not match'));
|
||||
if (!match) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(chalk_1.default.green('Key derivation completed successfully'));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ error: errorMessage }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
// Add auth-token command
|
||||
commander_1.program
|
||||
.command('auth-token')
|
||||
.description('Generate JWT authentication token signed with Ed25519 private key')
|
||||
.argument('<public-key>', '32-byte public key in hex format')
|
||||
.argument('<private-key>', '64-byte private key in hex format')
|
||||
.option('-e, --exp <seconds>', 'Token expiration in seconds from now (default: 86400 = 24 hours)', '86400')
|
||||
.option('-c, --claims <json>', 'Additional claims as JSON object (e.g., \'{"aud":"mqtt.example.com","sub":"device-123"}\')')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (publicKeyHex, privateKeyHex, options) => {
|
||||
try {
|
||||
const { createAuthToken } = await Promise.resolve().then(() => __importStar(require('./utils/auth-token')));
|
||||
// Clean up hex inputs
|
||||
const cleanPublicKey = publicKeyHex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
const cleanPrivateKey = privateKeyHex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
if (cleanPublicKey.length !== 64) {
|
||||
console.error(chalk_1.default.red('❌ Error: Public key must be exactly 32 bytes (64 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
if (cleanPrivateKey.length !== 128) {
|
||||
console.error(chalk_1.default.red('❌ Error: Private key must be exactly 64 bytes (128 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
const expSeconds = parseInt(options.exp);
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + expSeconds;
|
||||
const payload = {
|
||||
publicKey: cleanPublicKey.toUpperCase(),
|
||||
iat,
|
||||
exp
|
||||
};
|
||||
// Parse and merge additional claims if provided
|
||||
if (options.claims) {
|
||||
try {
|
||||
const additionalClaims = JSON.parse(options.claims);
|
||||
Object.assign(payload, additionalClaims);
|
||||
}
|
||||
catch (e) {
|
||||
console.error(chalk_1.default.red('❌ Error: Invalid JSON in --claims option'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const token = await createAuthToken(payload, cleanPrivateKey, cleanPublicKey.toUpperCase());
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
token,
|
||||
payload
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(token);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ error: errorMessage }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
// Add verify-token command
|
||||
commander_1.program
|
||||
.command('verify-token')
|
||||
.description('Verify JWT authentication token')
|
||||
.argument('<token>', 'JWT token to verify')
|
||||
.option('-p, --public-key <key>', 'Expected public key in hex format (optional)')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (token, options) => {
|
||||
try {
|
||||
const { verifyAuthToken } = await Promise.resolve().then(() => __importStar(require('./utils/auth-token')));
|
||||
const cleanToken = token.trim();
|
||||
let expectedPublicKey;
|
||||
if (options.publicKey) {
|
||||
const cleanKey = options.publicKey.replace(/\s+/g, '').replace(/^0x/i, '').toUpperCase();
|
||||
if (cleanKey.length !== 64) {
|
||||
console.error(chalk_1.default.red('❌ Error: Public key must be exactly 32 bytes (64 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
expectedPublicKey = cleanKey;
|
||||
}
|
||||
const payload = await verifyAuthToken(cleanToken, expectedPublicKey);
|
||||
if (payload) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isExpired = payload.exp && now > payload.exp;
|
||||
const timeToExpiry = payload.exp ? payload.exp - now : null;
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
valid: true,
|
||||
expired: isExpired,
|
||||
payload,
|
||||
timeToExpiry
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.green('✅ Token is valid'));
|
||||
console.log(chalk_1.default.cyan('\nPayload:'));
|
||||
console.log(` Public Key: ${payload.publicKey}`);
|
||||
console.log(` Issued At: ${new Date(payload.iat * 1000).toISOString()} (${payload.iat})`);
|
||||
if (payload.exp) {
|
||||
console.log(` Expires At: ${new Date(payload.exp * 1000).toISOString()} (${payload.exp})`);
|
||||
if (isExpired) {
|
||||
console.log(chalk_1.default.red(` Status: EXPIRED`));
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.green(` Status: Valid for ${timeToExpiry} more seconds`));
|
||||
}
|
||||
}
|
||||
// Show any additional claims
|
||||
const standardClaims = ['publicKey', 'iat', 'exp'];
|
||||
const customClaims = Object.keys(payload).filter(k => !standardClaims.includes(k));
|
||||
if (customClaims.length > 0) {
|
||||
console.log(chalk_1.default.cyan('\nCustom Claims:'));
|
||||
customClaims.forEach(key => {
|
||||
console.log(` ${key}: ${JSON.stringify(payload[key])}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
valid: false,
|
||||
error: 'Token verification failed'
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red('❌ Token verification failed'));
|
||||
console.error(chalk_1.default.yellow('Possible reasons:'));
|
||||
console.error(' - Invalid signature');
|
||||
console.error(' - Token format is incorrect');
|
||||
console.error(' - Public key mismatch (if --public-key was provided)');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ valid: false, error: errorMessage }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
commander_1.program.parse();
|
||||
//# sourceMappingURL=cli.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
import { DecryptionResult } from '../types/crypto';
|
||||
export declare class ChannelCrypto {
|
||||
/**
|
||||
* Decrypt GroupText message using MeshCore algorithm:
|
||||
* - HMAC-SHA256 verification with 2-byte MAC
|
||||
* - AES-128 ECB decryption
|
||||
*/
|
||||
static decryptGroupTextMessage(ciphertext: string, cipherMac: string, channelKey: string): DecryptionResult;
|
||||
/**
|
||||
* Calculate MeshCore channel hash from secret key
|
||||
* Returns the first byte of SHA256(secret) as hex string
|
||||
*/
|
||||
static calculateChannelHash(secretKeyHex: string): string;
|
||||
}
|
||||
//# sourceMappingURL=channel-crypto.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"channel-crypto.d.ts","sourceRoot":"","sources":["../../src/crypto/channel-crypto.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAGnD,qBAAa,aAAa;IACxB;;;;OAIG;IACH,MAAM,CAAC,uBAAuB,CAC5B,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,gBAAgB;IAuFnB;;;OAGG;IACH,MAAM,CAAC,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;CAK1D"}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChannelCrypto = void 0;
|
||||
const crypto_js_1 = require("crypto-js");
|
||||
const hex_1 = require("../utils/hex");
|
||||
class ChannelCrypto {
|
||||
/**
|
||||
* Decrypt GroupText message using MeshCore algorithm:
|
||||
* - HMAC-SHA256 verification with 2-byte MAC
|
||||
* - AES-128 ECB decryption
|
||||
*/
|
||||
static decryptGroupTextMessage(ciphertext, cipherMac, channelKey) {
|
||||
try {
|
||||
// convert hex strings to byte arrays
|
||||
const channelKey16 = (0, hex_1.hexToBytes)(channelKey);
|
||||
const macBytes = (0, hex_1.hexToBytes)(cipherMac);
|
||||
// MeshCore uses 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
const channelSecret = new Uint8Array(32);
|
||||
channelSecret.set(channelKey16, 0);
|
||||
// Step 1: Verify HMAC-SHA256 using full 32-byte channel secret
|
||||
const calculatedMac = (0, crypto_js_1.HmacSHA256)(crypto_js_1.enc.Hex.parse(ciphertext), crypto_js_1.enc.Hex.parse((0, hex_1.bytesToHex)(channelSecret)));
|
||||
const calculatedMacBytes = (0, hex_1.hexToBytes)(calculatedMac.toString(crypto_js_1.enc.Hex));
|
||||
const calculatedMacFirst2 = calculatedMacBytes.slice(0, 2);
|
||||
if (calculatedMacFirst2[0] !== macBytes[0] || calculatedMacFirst2[1] !== macBytes[1]) {
|
||||
return { success: false, error: 'MAC verification failed' };
|
||||
}
|
||||
// Step 2: Decrypt using AES-128 ECB with first 16 bytes of channel secret
|
||||
const keyWords = crypto_js_1.enc.Hex.parse(channelKey);
|
||||
const ciphertextWords = crypto_js_1.enc.Hex.parse(ciphertext);
|
||||
const decrypted = crypto_js_1.AES.decrypt(crypto_js_1.lib.CipherParams.create({ ciphertext: ciphertextWords }), keyWords, { mode: crypto_js_1.mode.ECB, padding: crypto_js_1.pad.NoPadding });
|
||||
const decryptedBytes = (0, hex_1.hexToBytes)(decrypted.toString(crypto_js_1.enc.Hex));
|
||||
if (!decryptedBytes || decryptedBytes.length < 5) {
|
||||
return { success: false, error: 'Decrypted content too short' };
|
||||
}
|
||||
// parse MeshCore format: timestamp(4) + flags(1) + message_text
|
||||
const timestamp = decryptedBytes[0] |
|
||||
(decryptedBytes[1] << 8) |
|
||||
(decryptedBytes[2] << 16) |
|
||||
(decryptedBytes[3] << 24);
|
||||
const flagsAndAttempt = decryptedBytes[4];
|
||||
// extract message text with UTF-8 decoding
|
||||
const messageBytes = decryptedBytes.slice(5);
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let messageText = decoder.decode(messageBytes);
|
||||
// remove null terminator if present
|
||||
const nullIndex = messageText.indexOf('\0');
|
||||
if (nullIndex >= 0) {
|
||||
messageText = messageText.substring(0, nullIndex);
|
||||
}
|
||||
// parse sender and message (format: "sender: message")
|
||||
const colonIndex = messageText.indexOf(': ');
|
||||
let sender;
|
||||
let content;
|
||||
if (colonIndex > 0 && colonIndex < 50) {
|
||||
const potentialSender = messageText.substring(0, colonIndex);
|
||||
if (!/[:\[\]]/.test(potentialSender)) {
|
||||
sender = potentialSender;
|
||||
content = messageText.substring(colonIndex + 2);
|
||||
}
|
||||
else {
|
||||
content = messageText;
|
||||
}
|
||||
}
|
||||
else {
|
||||
content = messageText;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
timestamp,
|
||||
flags: flagsAndAttempt,
|
||||
sender,
|
||||
message: content
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Decryption failed' };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Calculate MeshCore channel hash from secret key
|
||||
* Returns the first byte of SHA256(secret) as hex string
|
||||
*/
|
||||
static calculateChannelHash(secretKeyHex) {
|
||||
const hash = (0, crypto_js_1.SHA256)(crypto_js_1.enc.Hex.parse(secretKeyHex));
|
||||
const hashBytes = (0, hex_1.hexToBytes)(hash.toString(crypto_js_1.enc.Hex));
|
||||
return hashBytes[0].toString(16).padStart(2, '0');
|
||||
}
|
||||
}
|
||||
exports.ChannelCrypto = ChannelCrypto;
|
||||
//# sourceMappingURL=channel-crypto.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"channel-crypto.js","sourceRoot":"","sources":["../../src/crypto/channel-crypto.ts"],"names":[],"mappings":";AAAA,mFAAmF;AACnF,cAAc;;;AAEd,yCAAyE;AAEzE,sCAAsD;AAEtD,MAAa,aAAa;IACxB;;;;OAIG;IACH,MAAM,CAAC,uBAAuB,CAC5B,UAAkB,EAClB,SAAiB,EACjB,UAAkB;QAElB,IAAI,CAAC;YACH,qCAAqC;YACrC,MAAM,YAAY,GAAG,IAAA,gBAAU,EAAC,UAAU,CAAC,CAAC;YAC5C,MAAM,QAAQ,GAAG,IAAA,gBAAU,EAAC,SAAS,CAAC,CAAC;YAEvC,oEAAoE;YACpE,MAAM,aAAa,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;YACzC,aAAa,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC;YAEnC,+DAA+D;YAC/D,MAAM,aAAa,GAAG,IAAA,sBAAU,EAAC,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC,CAAC,CAAC;YACtG,MAAM,kBAAkB,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,QAAQ,CAAC,eAAG,CAAC,GAAG,CAAC,CAAC,CAAC;YACvE,MAAM,mBAAmB,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YAE3D,IAAI,mBAAmB,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,mBAAmB,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;gBACrF,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC;YAC9D,CAAC;YAED,0EAA0E;YAC1E,MAAM,QAAQ,GAAG,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YAC3C,MAAM,eAAe,GAAG,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YAElD,MAAM,SAAS,GAAG,eAAG,CAAC,OAAO,CAC3B,eAAG,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,eAAe,EAAE,CAAC,EACxD,QAAQ,EACR,EAAE,IAAI,EAAE,gBAAI,CAAC,GAAG,EAAE,OAAO,EAAE,eAAG,CAAC,SAAS,EAAE,CAC3C,CAAC;YAEF,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,SAAS,CAAC,QAAQ,CAAC,eAAG,CAAC,GAAG,CAAC,CAAC,CAAC;YAE/D,IAAI,CAAC,cAAc,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACjD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,6BAA6B,EAAE,CAAC;YAClE,CAAC;YAED,gEAAgE;YAChE,MAAM,SAAS,GAAG,cAAc,CAAC,CAAC,CAAC;gBAClB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;gBACxB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;gBACzB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;YAE3C,MAAM,eAAe,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC;YAE1C,2CAA2C;YAC3C,MAAM,YAAY,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAC7C,MAAM,OAAO,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC;YACzC,IAAI,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;YAE/C,oCAAoC;YACpC,MAAM,SAAS,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC5C,IAAI,SAAS,IAAI,CAAC,EAAE,CAAC;gBACnB,WAAW,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;YACpD,CAAC;YAED,uDAAuD;YACvD,MAAM,UAAU,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC7C,IAAI,MAA0B,CAAC;YAC/B,IAAI,OAAe,CAAC;YAEpB,IAAI,UAAU,GAAG,CAAC,IAAI,UAAU,GAAG,EAAE,EAAE,CAAC;gBACtC,MAAM,eAAe,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC;gBAC7D,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,eAAe,CAAC,EAAE,CAAC;oBACrC,MAAM,GAAG,eAAe,CAAC;oBACzB,OAAO,GAAG,WAAW,CAAC,SAAS,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC;gBAClD,CAAC;qBAAM,CAAC;oBACN,OAAO,GAAG,WAAW,CAAC;gBACxB,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,OAAO,GAAG,WAAW,CAAC;YACxB,CAAC;YAED,OAAO;gBACL,OAAO,EAAE,IAAI;gBACb,IAAI,EAAE;oBACJ,SAAS;oBACT,KAAK,EAAE,eAAe;oBACtB,MAAM;oBACN,OAAO,EAAE,OAAO;iBACjB;aACF,CAAC;QACJ,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,mBAAmB,EAAE,CAAC;QACjG,CAAC;IACH,CAAC;IAID;;;OAGG;IACH,MAAM,CAAC,oBAAoB,CAAC,YAAoB;QAC9C,MAAM,IAAI,GAAG,IAAA,kBAAM,EAAC,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC;QACjD,MAAM,SAAS,GAAG,IAAA,gBAAU,EAAC,IAAI,CAAC,QAAQ,CAAC,eAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QACrD,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACpD,CAAC;CACF;AA1GD,sCA0GC"}
|
||||
@@ -0,0 +1,48 @@
|
||||
export declare class Ed25519SignatureVerifier {
|
||||
/**
|
||||
* Verify an Ed25519 signature for MeshCore advertisement packets
|
||||
*
|
||||
* According to MeshCore protocol, the signed message for advertisements is:
|
||||
* timestamp (4 bytes LE) + flags (1 byte) + location (8 bytes LE, if present) + name (variable, if present)
|
||||
*/
|
||||
static verifyAdvertisementSignature(publicKeyHex: string, signatureHex: string, timestamp: number, appDataHex: string): Promise<boolean>;
|
||||
/**
|
||||
* Construct the signed message for MeshCore advertisements
|
||||
* According to MeshCore source (Mesh.cpp lines 242-248):
|
||||
* Format: public_key (32 bytes) + timestamp (4 bytes LE) + app_data (variable length)
|
||||
*/
|
||||
private static constructAdvertSignedMessage;
|
||||
/**
|
||||
* Get a human-readable description of what was signed
|
||||
*/
|
||||
static getSignedMessageDescription(publicKeyHex: string, timestamp: number, appDataHex: string): string;
|
||||
/**
|
||||
* Get the hex representation of the signed message for debugging
|
||||
*/
|
||||
static getSignedMessageHex(publicKeyHex: string, timestamp: number, appDataHex: string): string;
|
||||
/**
|
||||
* Derive Ed25519 public key from orlp/ed25519 private key format
|
||||
* This implements the same algorithm as orlp/ed25519's ed25519_derive_pub()
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
static derivePublicKey(privateKeyHex: string): Promise<string>;
|
||||
/**
|
||||
* Derive Ed25519 public key from orlp/ed25519 private key format (synchronous version)
|
||||
* This implements the same algorithm as orlp/ed25519's ed25519_derive_pub()
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
static derivePublicKeySync(privateKeyHex: string): string;
|
||||
/**
|
||||
* Validate that a private key correctly derives to the expected public key
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format
|
||||
* @param expectedPublicKeyHex - Expected 32-byte public key in hex format
|
||||
* @returns true if the private key derives to the expected public key
|
||||
*/
|
||||
static validateKeyPair(privateKeyHex: string, expectedPublicKeyHex: string): Promise<boolean>;
|
||||
}
|
||||
//# sourceMappingURL=ed25519-verifier.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ed25519-verifier.d.ts","sourceRoot":"","sources":["../../src/crypto/ed25519-verifier.ts"],"names":[],"mappings":"AAyEA,qBAAa,wBAAwB;IACnC;;;;;OAKG;WACU,4BAA4B,CACvC,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC;IAkBnB;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,4BAA4B;IAuB3C;;OAEG;IACH,MAAM,CAAC,2BAA2B,CAChC,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,MAAM;IAIT;;OAEG;IACH,MAAM,CAAC,mBAAmB,CACxB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,MAAM;IAMT;;;;;;OAMG;WACU,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAepE;;;;;;OAMG;IACH,MAAM,CAAC,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM;IAezD;;;;;;OAMG;WACU,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAQpG"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user