mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
397 Commits
actually-c
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9e68544fe9 | ||
|
|
f059756064 | ||
|
|
95bacc4caf | ||
|
|
2581cc6af7 | ||
|
|
05df314619 | ||
|
|
00ca4afa8d | ||
|
|
2496d70c4b | ||
|
|
4b05dc2f41 | ||
|
|
b8cdae8a03 | ||
|
|
3bad3cb21c | ||
|
|
f118d5e222 | ||
|
|
e0d87c4df3 | ||
|
|
ed83d1b2c4 | ||
|
|
d988309a2f | ||
|
|
7c37133856 | ||
|
|
0bde67d66c | ||
|
|
56d4fa707a | ||
|
|
a8af9b10f3 | ||
|
|
18ac86b4c0 | ||
|
|
e504f4de33 | ||
|
|
9c4b049c8d | ||
|
|
330c5efb31 | ||
|
|
f993110ec4 | ||
|
|
c891a23a41 | ||
|
|
1f1c0faccc | ||
|
|
727ac913de | ||
|
|
a55166989e | ||
|
|
a2b211a8bc | ||
|
|
5d90727718 | ||
|
|
0ad17c8d1f | ||
|
|
365728be02 | ||
|
|
7cad4a98dd | ||
|
|
bac4db6b0a | ||
|
|
60c0262490 | ||
|
|
ce99d63701 | ||
|
|
fc27361e37 | ||
|
|
dcd473de6c | ||
|
|
57e6ba534a | ||
|
|
17f6a2b8c5 | ||
|
|
884972f9e0 | ||
|
|
60455cdd7b | ||
|
|
194852ed16 | ||
|
|
6a3510ce2e | ||
|
|
c40603a36f | ||
|
|
2e8a4fde0a | ||
|
|
171b4405e5 | ||
|
|
c5fd0292b8 | ||
|
|
66cbf98b74 | ||
|
|
b3606169fe | ||
|
|
d4a2b9fac8 | ||
|
|
26fbfcd015 | ||
|
|
f4a383082e | ||
|
|
b91b2d5d7b | ||
|
|
24166e92e8 | ||
|
|
f003bda7b2 | ||
|
|
a406e00229 | ||
|
|
56f8b796e6 | ||
|
|
6ec2350b9a | ||
|
|
566181faed | ||
|
|
27942975e2 | ||
|
|
1c2fb148bc | ||
|
|
684724913f | ||
|
|
0826030f1c | ||
|
|
fb11690585 | ||
|
|
5dcb52914b | ||
|
|
b4a0b1c515 | ||
|
|
81c166bb8d | ||
|
|
71359e437f | ||
|
|
932ea6b65d | ||
|
|
2757f25eb9 | ||
|
|
561c8cf9c0 | ||
|
|
1b76211d53 | ||
|
|
b1a0456a05 | ||
|
|
f7f696bf10 | ||
|
|
5c0f3df806 | ||
|
|
c30ed0b4bc | ||
|
|
440ab14d7f | ||
|
|
c25b21469e | ||
|
|
17e526697f | ||
|
|
27cd3bd710 | ||
|
|
c0f740d5f9 | ||
|
|
cc6e788021 | ||
|
|
033af4027d | ||
|
|
cc12128041 | ||
|
|
4f3d8a7838 | ||
|
|
559935e3d5 | ||
|
|
ecb748b9e3 | ||
|
|
31bb1e7d22 | ||
|
|
72b66214fa | ||
|
|
2125653978 | ||
|
|
31302b4972 | ||
|
|
c6a8c3835c | ||
|
|
4b84f609b7 | ||
|
|
a22224980e | ||
|
|
ced0791c05 | ||
|
|
47867c50b8 | ||
|
|
1a4f57a03e | ||
|
|
5d7a313c53 | ||
|
|
b9de3b7dd7 | ||
|
|
7306627ac7 | ||
|
|
1bd31d68d9 | ||
|
|
152eab99db | ||
|
|
cba9e20698 | ||
|
|
619973bdf0 | ||
|
|
ef4c79bc80 | ||
|
|
88d5a76081 | ||
|
|
9193d113fe | ||
|
|
fd0f901546 | ||
|
|
40d27dd8d6 | ||
|
|
54706700ab | ||
|
|
00aa212049 | ||
|
|
7542cc1142 | ||
|
|
d525188cce | ||
|
|
d635914d4b | ||
|
|
e806430a73 | ||
|
|
2e23733f41 | ||
|
|
7e52982399 | ||
|
|
40dde4647a | ||
|
|
7463f4e032 | ||
|
|
a7b5dcc9d8 | ||
|
|
1e53fe9515 | ||
|
|
1477900f6f | ||
|
|
11f07f3501 | ||
|
|
6d0505ade6 | ||
|
|
9e3b1d03a9 | ||
|
|
9352b272d5 | ||
|
|
c90a30787a | ||
|
|
2321411ef0 | ||
|
|
a8a8f6e08b | ||
|
|
f9eb46f2ab | ||
|
|
41bf4eb73a | ||
|
|
e0ca50afc8 | ||
|
|
d08a113fc8 | ||
|
|
f490cc756f | ||
|
|
3232075767 | ||
|
|
a9d650ecd4 | ||
|
|
7c23dcf6d9 | ||
|
|
6e4872e25b | ||
|
|
ef2b22a865 | ||
|
|
a4d8707479 | ||
|
|
0e25bd2281 | ||
|
|
56fde32970 | ||
|
|
1a59eb89fa | ||
|
|
65b74b624b | ||
|
|
95e8bcca08 | ||
|
|
e8ddba0131 | ||
|
|
8ca48cd6bc | ||
|
|
72f12d80e5 | ||
|
|
d2f5bd84a8 | ||
|
|
cbe091ad90 | ||
|
|
1f853aa54e | ||
|
|
8457799a60 | ||
|
|
591d333970 | ||
|
|
54a03a9467 | ||
|
|
de7ab37998 | ||
|
|
3042beaf27 | ||
|
|
d4a7b37fa9 | ||
|
|
6a3a99fe9f | ||
|
|
7d340f19e0 | ||
|
|
95d806717b | ||
|
|
7f426ece4e | ||
|
|
7bb0e5e719 | ||
|
|
a495f284ea | ||
|
|
7df21d03f2 | ||
|
|
be007322d2 | ||
|
|
58900f7649 | ||
|
|
877649ddc7 | ||
|
|
24685038f8 | ||
|
|
1e73cbf266 | ||
|
|
241f94ceaf | ||
|
|
e157826364 | ||
|
|
89d311e4ae | ||
|
|
0d03945b81 | ||
|
|
945053c20a | ||
|
|
1f3042f360 | ||
|
|
3756579f9d | ||
|
|
6e3cf28577 | ||
|
|
9afaee24a0 | ||
|
|
c91449260d | ||
|
|
36098f62b8 | ||
|
|
8bb408180e | ||
|
|
b34bc1491a | ||
|
|
4919f551f8 | ||
|
|
5a82d469b4 | ||
|
|
7b2d5b817e | ||
|
|
a598cbbd1a | ||
|
|
76db547f50 | ||
|
|
1c4d6c07a8 | ||
|
|
908a479fa6 | ||
|
|
b14ad71eca | ||
|
|
57d007dec2 | ||
|
|
430b5aaba7 | ||
|
|
0fcf6a5653 | ||
|
|
3394183892 |
@@ -29,6 +29,7 @@ frontend/src/test/
|
||||
# Docs
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSES.md
|
||||
|
||||
# Other
|
||||
references/
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,11 +8,12 @@ wheels/
|
||||
# Virtual environments
|
||||
.venv
|
||||
frontend/node_modules/
|
||||
frontend/package-lock.json
|
||||
frontend/test-results/
|
||||
|
||||
# Frontend build output (built from source by end users)
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
frontend/.eslintcache
|
||||
|
||||
# reference libraries
|
||||
references/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
225
AGENTS.md
225
AGENTS.md
@@ -4,55 +4,54 @@
|
||||
|
||||
**NEVER make git commits.** A human must make all commits. You may stage files and prepare commit messages, but do not run `git commit`.
|
||||
|
||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, make sure you run the following and that they all pass green:
|
||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||
|
||||
```bash
|
||||
uv run ruff check app/ tests/ --fix # check for python violations
|
||||
uv run ruff format app/ tests/ # format python
|
||||
uv run pyright app/ # type check python
|
||||
PYTHONPATH=. uv run pytest tests/ -v # test python
|
||||
|
||||
cd frontend/ # move to frontend directory
|
||||
npm run lint:fix # fix lint violations
|
||||
npm run format # format the code
|
||||
npm run build # run a frontend build
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
|
||||
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend sequentially. All checks must pass green.
|
||||
|
||||
## 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)
|
||||
- `frontend/src/components/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
|
||||
┌──────┴──────┐
|
||||
@@ -78,44 +77,85 @@ 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) — 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
|
||||
2. **Parallel storage**: Messages stored both decrypted (when possible) and as raw packets
|
||||
3. **Extended capacity**: Server stores contacts/channels beyond radio limits (~350 contacts, ~40 channels)
|
||||
4. **Real-time updates**: WebSocket pushes events; REST for actions
|
||||
4. **Real-time updates**: WebSocket pushes events; REST for actions; optional MQTT forwarding
|
||||
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.
|
||||
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
|
||||
|
||||
Raw packet handling uses two identities by design:
|
||||
- **`id` (DB packet row ID)**: storage identity from payload-hash deduplication (path bytes are excluded), so repeated payloads share one stored raw-packet row.
|
||||
- **`observation_id` (WebSocket only)**: realtime observation identity, unique per RF arrival, so path-diverse repeats are still visible in-session.
|
||||
|
||||
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 + 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_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.
|
||||
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
|
||||
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Incoming Messages
|
||||
|
||||
1. Radio receives message → MeshCore library emits event
|
||||
2. `event_handlers.py` catches event → stores in database
|
||||
3. `ws_manager` broadcasts to connected clients
|
||||
1. Radio receives raw bytes → `packet_processor.py` parses, decrypts, deduplicates, and stores in database (primary path via `RX_LOG_DATA` event)
|
||||
2. `event_handlers.py` handles higher-level events (`CONTACT_MSG_RECV`, `ACK`) as a fallback/supplement
|
||||
3. `broadcast_event()` in `websocket.py` fans out to both WebSocket clients and MQTT
|
||||
4. Frontend `useWebSocket` receives → updates React state
|
||||
|
||||
### Outgoing Messages
|
||||
|
||||
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
|
||||
|
||||
@@ -123,7 +163,9 @@ The following are **deliberate design choices**, not bugs. They are documented i
|
||||
|
||||
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
||||
|
||||
**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` increments the ack count on the original and adds the new path. There is no timestamp-windowed matching; deduplication is exact-match only.
|
||||
**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.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -133,14 +175,17 @@ The following are **deliberate design choices**, not bugs. They are documented i
|
||||
│ ├── AGENTS.md # Backend documentation
|
||||
│ ├── main.py # App entry, lifespan
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── repository.py # Database CRUD
|
||||
│ ├── 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, fanout)
|
||||
│ ├── event_handlers.py # Radio events
|
||||
│ ├── decoder.py # Packet decryption
|
||||
│ └── websocket.py # Real-time broadcasts
|
||||
│ ├── websocket.py # Real-time broadcasts
|
||||
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (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/
|
||||
@@ -148,7 +193,12 @@ The following are **deliberate design choices**, not bugs. They are documented i
|
||||
│ │ ├── MapView.tsx # Leaflet map showing node locations
|
||||
│ │ └── ...
|
||||
│ └── vite.config.ts
|
||||
├── references/meshcore_py/ # MeshCore Python library
|
||||
├── scripts/
|
||||
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (sequential)
|
||||
│ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
│ ├── e2e.sh # End-to-end test runner
|
||||
│ └── publish.sh # Version bump, changelog, docker build & push
|
||||
├── remoteterm.service # Systemd unit file for production deployment
|
||||
├── tests/ # Backend tests (pytest)
|
||||
├── data/ # SQLite database (runtime)
|
||||
└── pyproject.toml # Python dependencies
|
||||
@@ -207,9 +257,20 @@ 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_messages_search.py` - Message search, around endpoint, forward pagination
|
||||
- `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_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
||||
- `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_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
||||
- `tests/test_real_crypto.py` - Real cryptographic operations
|
||||
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
@@ -220,20 +281,7 @@ npm run test:run
|
||||
|
||||
### Before Completing Changes
|
||||
|
||||
**Always run both backend and frontend validation before finishing any changes:**
|
||||
|
||||
```bash
|
||||
# From project root - run backend tests
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
# From project root - run frontend tests and build
|
||||
cd frontend && npm run test:run && npm run build
|
||||
```
|
||||
|
||||
This catches:
|
||||
- Type mismatches between frontend and backend (e.g., missing fields in TypeScript interfaces)
|
||||
- Breaking changes to shared types or API contracts
|
||||
- Runtime errors that only surface during compilation
|
||||
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** This runs all linting, formatting, type checking, tests, and builds sequentially, catching type mismatches, breaking changes, and compilation errors. This is not necessary for docs-only changes.
|
||||
|
||||
## API Summary
|
||||
|
||||
@@ -241,33 +289,49 @@ 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/radio/config` | Radio configuration, including `path_hash_mode` and `path_hash_mode_supported` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, 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/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| GET | `/api/contacts/{key}` | Get contact by public key or prefix |
|
||||
| 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) |
|
||||
| DELETE | `/api/contacts/{key}` | Delete contact |
|
||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||
| POST | `/api/contacts/sync` | Pull from radio |
|
||||
| POST | `/api/contacts/{key}/add-to-radio` | Push contact to radio |
|
||||
| POST | `/api/contacts/{key}/remove-from-radio` | Remove contact from radio |
|
||||
| POST | `/api/contacts/{key}/mark-read` | Mark contact conversation as read |
|
||||
| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater |
|
||||
| POST | `/api/contacts/{key}/command` | Send CLI command to repeater |
|
||||
| POST | `/api/contacts/{key}/trace` | Trace route to contact |
|
||||
| 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}/routing-override` | Set or clear a forced routing override |
|
||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||
| 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/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| GET | `/api/channels/{key}` | Get channel by key |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| 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}/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 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 |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
@@ -276,7 +340,14 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| 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) |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
## Key Concepts
|
||||
@@ -293,12 +364,14 @@ 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.
|
||||
|
||||
### Message Types
|
||||
|
||||
@@ -310,7 +383,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
|
||||
Read state (`last_read_at`) is tracked **server-side** for consistency across devices:
|
||||
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
|
||||
- Updated via `POST /api/contacts/{key}/mark-read` and `POST /api/channels/{key}/mark-read`
|
||||
- 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)
|
||||
|
||||
@@ -320,6 +393,14 @@ 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).
|
||||
|
||||
### Fanout Bus (MQTT, Bots, Webhooks, Apprise)
|
||||
|
||||
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`.
|
||||
|
||||
`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.
|
||||
|
||||
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
|
||||
|
||||
The server can decrypt packets using stored keys, both in real-time and for historical packets.
|
||||
@@ -357,10 +438,22 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_TCP_PORT` | `4000` | 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_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `experimental_channel_double_send`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. 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`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `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, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
|
||||
|
||||
`experimental_channel_double_send` is an opt-in experimental setting: when enabled, channel sends perform a second byte-perfect resend after a 3-second delay.
|
||||
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.
|
||||
|
||||
188
CHANGELOG.md
188
CHANGELOG.md
@@ -1,3 +1,170 @@
|
||||
## [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
|
||||
|
||||
## [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
|
||||
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
|
||||
|
||||
## [2.0.1] - 2026-02-16
|
||||
|
||||
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 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
|
||||
|
||||
## [1.9.3] - 2026-02-12
|
||||
|
||||
Feature: Upgrade the room finder to support two-word rooms
|
||||
@@ -5,7 +172,7 @@ 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: Move tests to isolated memory DB
|
||||
Bugfix: Mention case sensitivity
|
||||
Bugfix: Stale header retention on settings page view
|
||||
Bugfix: Non-isolated path writing
|
||||
@@ -58,30 +225,13 @@ 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
|
||||
|
||||
## [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
|
||||
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
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -1,13 +1,15 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-slim AS frontend-builder
|
||||
|
||||
ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/package.json frontend/.npmrc ./
|
||||
RUN npm install
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
||||
|
||||
|
||||
# Stage 2: Python runtime
|
||||
@@ -27,6 +29,9 @@ RUN uv sync --frozen --no-dev
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
|
||||
# Copy license attributions
|
||||
COPY LICENSES.md ./
|
||||
|
||||
# Copy built frontend from first stage
|
||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
||||
|
||||
@@ -35,5 +40,5 @@ RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
# Run the application (we retain root for max compatibility)
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
1532
LICENSES.md
Normal file
1532
LICENSES.md
Normal file
File diff suppressed because it is too large
Load Diff
168
README.md
168
README.md
@@ -2,26 +2,30 @@
|
||||
|
||||
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
|
||||
|
||||
* Send and receive DMs and GroupTexts
|
||||
* Send and receive DMs and channel messages
|
||||
* Cache all received packets, decrypting as you gain keys
|
||||
* 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
|
||||
* Brute force hashtag room names for GroupTexts you don't have keys for yet
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only)
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app
|
||||
* 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.
|
||||
**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. 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 access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
|
||||
|
||||

|
||||

|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is entirely vibecoded slop -- 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.
|
||||
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`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+ (for frontend development only)
|
||||
- Node.js LTS or current (20, 22, 24, 25)
|
||||
- [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
|
||||
|
||||
@@ -39,19 +43,25 @@ ls /dev/ttyUSB* /dev/ttyACM*
|
||||
#######
|
||||
ls /dev/cu.usbserial-* /dev/cu.usbmodem*
|
||||
|
||||
###########
|
||||
# Windows
|
||||
###########
|
||||
# In PowerShell:
|
||||
Get-CimInstance Win32_SerialPort | Select-Object DeviceID, Caption
|
||||
|
||||
######
|
||||
# WSL2
|
||||
######
|
||||
# Run this in an elevated PowerShell (not WSL) window
|
||||
winget install usbipd
|
||||
|
||||
# restart console
|
||||
|
||||
# find device ID (e.g. 3-8)
|
||||
# then find device ID
|
||||
usbipd list
|
||||
|
||||
# attach device to WSL
|
||||
usbipd bind --busid 3-8
|
||||
# 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>
|
||||
|
||||
@@ -85,31 +95,59 @@ MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:a
|
||||
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
|
||||
```
|
||||
|
||||
On Windows (PowerShell), set environment variables as a separate statement:
|
||||
```powershell
|
||||
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access at http://localhost:8000
|
||||
|
||||
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
|
||||
|
||||
## Docker
|
||||
## Docker Compose
|
||||
|
||||
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
|
||||
|
||||
```bash
|
||||
# Serial
|
||||
docker run -d \
|
||||
--device=/dev/ttyUSB0 \
|
||||
-v remoteterm-data:/app/data \
|
||||
-p 8000:8000 \
|
||||
jkingsman/remote-terminal-for-meshcore:latest
|
||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
||||
|
||||
# TCP
|
||||
docker run -d \
|
||||
-e MESHCORE_TCP_HOST=192.168.1.100 \
|
||||
-e MESHCORE_TCP_PORT=4000 \
|
||||
-v remoteterm-data:/app/data \
|
||||
-p 8000:8000 \
|
||||
jkingsman/remote-terminal-for-meshcore:latest
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
||||
|
||||
```yaml
|
||||
build: .
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
image: jkingsman/remoteterm-meshcore:latest
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Development
|
||||
@@ -118,12 +156,21 @@ docker run -d \
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run uvicorn app.main:app --reload
|
||||
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
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
```powershell
|
||||
uv sync
|
||||
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
@@ -137,10 +184,16 @@ 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 bakend, and a lint + autoformat on the frontend.
|
||||
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:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>But how?</summary>
|
||||
<summary>Or run individual checks</summary>
|
||||
|
||||
```bash
|
||||
# python
|
||||
@@ -170,14 +223,17 @@ npm run build # build the frontend
|
||||
| `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_MAX_RADIO_CONTACTS` | 200 | Max recent contacts to keep on radio for DM ACKs |
|
||||
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. If that audit ever finds radio data that was not surfaced through event subscription, the backend logs an error and the UI shows a toast telling the operator to check the 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 `get_msg()` safety net.
|
||||
|
||||
## Additional Setup
|
||||
|
||||
<details>
|
||||
<summary>HTTPS (Required for WebGPU Cracking outside localhost)</summary>
|
||||
<summary>HTTPS (Required for WebGPU room-finding outside localhost)</summary>
|
||||
|
||||
WebGPU requires a secure context. When not on `localhost`, serve over HTTPS:
|
||||
|
||||
@@ -186,21 +242,21 @@ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -node
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
|
||||
```
|
||||
|
||||
For Docker:
|
||||
For Docker Compose, generate the cert and add the volume mounts and command override to `docker-compose.yaml`:
|
||||
|
||||
```bash
|
||||
# generate TLS cert
|
||||
# generate snakeoil TLS cert
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
|
||||
```
|
||||
|
||||
# run with cert
|
||||
docker run -d \
|
||||
--device=/dev/ttyUSB0 \
|
||||
-v remoteterm-data:/app/data \
|
||||
-v $(pwd)/cert.pem:/app/cert.pem:ro \
|
||||
-v $(pwd)/key.pem:/app/key.pem:ro \
|
||||
-p 8000:8000 \
|
||||
jkingsman/remote-terminal-for-meshcore:latest \
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
|
||||
Then add the key and cert to the `remoteterm` service in `docker-compose.yaml`, and add an explicit launch command that uses them:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
|
||||
@@ -209,6 +265,8 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
||||
<details>
|
||||
<summary>Systemd Service (Linux)</summary>
|
||||
|
||||
Assumes you're running from `/opt/remoteterm`; update commands and `remoteterm.service` if you're running elsewhere.
|
||||
|
||||
```bash
|
||||
# Create service user
|
||||
sudo useradd -r -m -s /bin/false remoteterm
|
||||
@@ -245,17 +303,41 @@ Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if n
|
||||
<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 and an available autodetect-able radio, as well as 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
|
||||
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.
|
||||
|
||||
216
app/AGENTS.md
216
app/AGENTS.md
@@ -8,9 +8,17 @@ Keep it aligned with `app/` source files and router behavior.
|
||||
- FastAPI
|
||||
- aiosqlite
|
||||
- Pydantic
|
||||
- MeshCore Python library (`references/meshcore_py`)
|
||||
- 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
|
||||
@@ -19,16 +27,26 @@ app/
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations.py # Schema migrations (SQLite user_version)
|
||||
├── models.py # Pydantic request/response models
|
||||
├── repository.py # Data access layer
|
||||
├── radio.py # RadioManager + auto-reconnect monitor
|
||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||
├── 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_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_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
|
||||
├── bot.py # Bot execution and outbound bot sends
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
@@ -40,6 +58,9 @@ app/
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── settings.py
|
||||
├── fanout.py
|
||||
├── repeaters.py
|
||||
├── statistics.py
|
||||
└── ws.py
|
||||
```
|
||||
|
||||
@@ -49,24 +70,36 @@ 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.
|
||||
3. Shared message-domain services create/update `messages` and shape WS payloads.
|
||||
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
|
||||
|
||||
### 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. 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.
|
||||
- On reconnect, monitor runs `post_connect_setup()` before broadcasting healthy state.
|
||||
- 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.
|
||||
- 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.
|
||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
||||
- `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`).
|
||||
@@ -75,7 +108,20 @@ app/
|
||||
### Echo/repeat dedup
|
||||
|
||||
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
|
||||
- Duplicate insert is treated as an echo/repeat; ACK count/path list is updated.
|
||||
- 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.
|
||||
|
||||
### Raw packet dedup policy
|
||||
|
||||
- Raw packet storage deduplicates by payload hash (`RawPacketRepository.create`), excluding routing/path bytes.
|
||||
- Stored packet `id` is therefore a payload identity, not a per-arrival identity.
|
||||
- Realtime raw-packet WS broadcasts include `observation_id` (unique per RF arrival) in addition to `id`.
|
||||
- Frontend packet-feed features should key/dedupe by `observation_id`; use `id` only as the storage reference.
|
||||
- Message-layer repeat handling (`_handle_duplicate_message` + `MessageRepository.add_path`) is separate from raw-packet storage dedup.
|
||||
|
||||
### Contact sync throttle
|
||||
|
||||
- `sync_recent_contacts_to_radio()` sets `_last_contact_sync = now` before the sync completes.
|
||||
- This is intentional: if sync fails, the next attempt is still throttled to prevent a retry-storm against a flaky radio. Contacts will resync on the next scheduled cycle or on reconnect.
|
||||
|
||||
### Periodic advertisement
|
||||
|
||||
@@ -83,14 +129,23 @@ app/
|
||||
- `0` means disabled.
|
||||
- Last send time tracked in `app_settings.last_advert_time`.
|
||||
|
||||
### Fanout bus
|
||||
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise) 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`
|
||||
|
||||
### Radio
|
||||
- `GET /radio/config`
|
||||
- `PATCH /radio/config`
|
||||
- `GET /radio/config` — includes `path_hash_mode` and `path_hash_mode_supported`
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/reboot`
|
||||
@@ -98,29 +153,44 @@ app/
|
||||
|
||||
### Contacts
|
||||
- `GET /contacts`
|
||||
- `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`
|
||||
- `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}/telemetry`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `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/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}/detail`
|
||||
- `GET /channels/{key}`
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/flood-scope-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`
|
||||
@@ -135,8 +205,19 @@ app/
|
||||
- `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)
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
|
||||
### WebSocket
|
||||
- `WS /ws`
|
||||
|
||||
@@ -147,24 +228,33 @@ app/
|
||||
- `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`
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
|
||||
- `channels`
|
||||
- `messages`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `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`
|
||||
|
||||
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`
|
||||
- `experimental_channel_double_send`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
@@ -172,7 +262,10 @@ Main tables:
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`
|
||||
|
||||
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
@@ -190,13 +283,80 @@ Run backend tests:
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
High-signal suites:
|
||||
- `tests/test_packet_pipeline.py`
|
||||
- `tests/test_event_handlers.py`
|
||||
- `tests/test_send_messages.py`
|
||||
- `tests/test_radio.py`
|
||||
- `tests/test_api.py`
|
||||
- `tests/test_migrations.py`
|
||||
Test suites:
|
||||
|
||||
```text
|
||||
tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_config.py # Configuration validation
|
||||
├── 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_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_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── 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_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── 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_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
├── test_radio_sync.py # Polling, sync, advertisement
|
||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||
├── test_repository.py # Data access layer
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_block_lists.py # Blocked keys/names filtering
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_channel_sender_backfill.py # Sender key backfill for channel messages
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
```
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
### Sender timestamps are 1-second resolution (protocol constraint)
|
||||
|
||||
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")`.
|
||||
|
||||
### Outgoing DM echoes remain undecrypted
|
||||
|
||||
When our own outgoing DM is heard back via `RX_LOG_DATA` (self-echo, loopback), `_process_direct_message` passes `our_public_key=None` for the outgoing direction, disabling the outbound hash check in the decoder. The decoder's inbound check (`src_hash == their_first_byte`) fails because the source is us, not the contact — so decryption returns `None`. This is by design: outgoing DMs are stored directly by the send endpoint, so no message is lost.
|
||||
|
||||
### Infinite setup retry on connection monitor
|
||||
|
||||
When `post_connect_setup()` fails (e.g. `export_and_store_private_key` raises `RuntimeError` because the radio didn't respond), `_setup_complete` is never set to `True`. The connection monitor sees `connected and not setup_complete` and retries every 5 seconds — indefinitely. This is intentional: the radio may be rebooting, waking from sleep, or otherwise temporarily unresponsive. We keep retrying so that setup completes automatically once the radio becomes available, without requiring manual intervention.
|
||||
|
||||
### DELETE channel returns 200 for non-existent keys
|
||||
|
||||
`DELETE /api/channels/{key}` returns `{"status": "ok"}` even if the key didn't exist. This is intentional — the postcondition is "channel doesn't exist," which is satisfied regardless of whether it existed before. No 404 needed.
|
||||
|
||||
### Contact lat/lon 0.0 vs NULL
|
||||
|
||||
MeshCore uses `0.0` as the sentinel for "no GPS coordinates" (see `models.py` `to_radio_dict`). The upsert SQL uses `COALESCE(excluded.lat, contacts.lat)`, which preserves existing values when the new value is `NULL` — but `0.0` is not `NULL`, so it overwrites previously valid coordinates. This is intentional: we always want the most recent location data. If a device stops broadcasting GPS, the old coordinates are presumably stale/wrong, so overwriting with "not available" (`0.0`) is the correct behavior.
|
||||
|
||||
## Editing Checklist
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import logging.config
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
@@ -16,6 +17,8 @@ class Settings(BaseSettings):
|
||||
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
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
@@ -47,10 +50,91 @@ class Settings(BaseSettings):
|
||||
settings = Settings()
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
|
||||
|
||||
@@ -15,19 +15,25 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
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
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -42,11 +48,14 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
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.
|
||||
UNIQUE(type, conversation_key, text, sender_timestamp)
|
||||
-- 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.
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
@@ -54,15 +63,43 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
message_id INTEGER,
|
||||
payload_hash TEXT,
|
||||
payload_hash BLOB,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key);
|
||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
path_hex TEXT NOT NULL,
|
||||
path_len INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
);
|
||||
|
||||
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));
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||
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_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);
|
||||
"""
|
||||
|
||||
|
||||
@@ -76,6 +113,17 @@ class Database:
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._connection = await aiosqlite.connect(self.db_path)
|
||||
self._connection.row_factory = aiosqlite.Row
|
||||
|
||||
# WAL mode: faster writes, concurrent readers during writes, no journal file churn.
|
||||
# Persists in the DB file but we set it explicitly on every connection.
|
||||
await self._connection.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
# Incremental auto-vacuum: freed pages are reclaimable via
|
||||
# PRAGMA incremental_vacuum without a full VACUUM. Must be set before
|
||||
# the first table is created (for new databases); for existing databases
|
||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
|
||||
await self._connection.executescript(SCHEMA)
|
||||
await self._connection.commit()
|
||||
logger.debug("Database schema initialized")
|
||||
|
||||
@@ -79,9 +79,10 @@ 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:
|
||||
@@ -100,86 +101,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
|
||||
|
||||
|
||||
@@ -327,7 +278,6 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
|
||||
# Parse fixed-position fields
|
||||
public_key = payload[0:32].hex()
|
||||
timestamp = int.from_bytes(payload[32:36], byteorder="little")
|
||||
# signature = payload[36:100] # Not currently verified
|
||||
flags = payload[100]
|
||||
|
||||
# Parse flags
|
||||
@@ -530,8 +480,10 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
message_bytes = decrypted[5:]
|
||||
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
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"""Shared dependencies for FastAPI routers."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as 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
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance."""
|
||||
return radio_manager.require_connected()
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, ContactRepository, MessageRepository
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactRepository,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.contact_reconciliation import (
|
||||
claim_prefix_messages_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -18,33 +26,17 @@ logger = logging.getLogger(__name__)
|
||||
# Track active subscriptions so we can unsubscribe before re-registering
|
||||
# This prevents handler duplication after reconnects
|
||||
_active_subscriptions: list["Subscription"] = []
|
||||
|
||||
|
||||
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
|
||||
_pending_acks: dict[str, tuple[int, float, int]] = {}
|
||||
_pending_acks = dm_ack_tracker._pending_acks
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
"""Compatibility wrapper for pending DM ACK tracking."""
|
||||
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)
|
||||
def cleanup_expired_acks() -> None:
|
||||
"""Compatibility wrapper for expiring stale DM ACK entries."""
|
||||
dm_ack_tracker.cleanup_expired_acks()
|
||||
|
||||
|
||||
async def on_contact_message(event: "Event") -> None:
|
||||
@@ -86,7 +78,7 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_pubkey = contact.public_key.lower()
|
||||
|
||||
# Promote any prefix-stored messages to this full key
|
||||
await MessageRepository.claim_prefix_messages(sender_pubkey)
|
||||
await claim_prefix_messages_for_contact(public_key=sender_pubkey, log=logger)
|
||||
|
||||
# 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.
|
||||
@@ -99,18 +91,26 @@ async def on_contact_message(event: "Event") -> None:
|
||||
|
||||
# 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", ""),
|
||||
ts = payload.get("sender_timestamp")
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
sender_name = contact.name if contact else None
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
message = await create_fallback_direct_message(
|
||||
conversation_key=sender_pubkey,
|
||||
sender_timestamp=payload.get("sender_timestamp") or received_at,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=payload.get("path"),
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_pubkey,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
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])
|
||||
return
|
||||
@@ -119,49 +119,10 @@ async def on_contact_message(event: "Event") -> None:
|
||||
# (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 = [{"path": path or "", "received_at": received_at}] if path is not None else None
|
||||
|
||||
# Broadcast the new message
|
||||
broadcast_event(
|
||||
"message",
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "PRIV",
|
||||
"conversation_key": sender_pubkey,
|
||||
"text": payload.get("text", ""),
|
||||
"sender_timestamp": payload.get("sender_timestamp"),
|
||||
"received_at": received_at,
|
||||
"paths": paths,
|
||||
"txt_type": txt_type,
|
||||
"signature": payload.get("signature"),
|
||||
"outgoing": False,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def on_rx_log_data(event: "Event") -> None:
|
||||
"""Store raw RF packet data and process via centralized packet processor.
|
||||
@@ -189,15 +150,67 @@ async def on_rx_log_data(event: "Event") -> None:
|
||||
async def on_path_update(event: "Event") -> None:
|
||||
"""Handle path update events."""
|
||||
payload = event.payload
|
||||
logger.debug("Path update for %s", payload.get("pubkey_prefix"))
|
||||
public_key = str(payload.get("public_key", "")).lower()
|
||||
pubkey_prefix = str(payload.get("pubkey_prefix", "")).lower()
|
||||
|
||||
pubkey_prefix = payload.get("pubkey_prefix", "")
|
||||
path = payload.get("path", "")
|
||||
path_len = payload.get("path_len", -1)
|
||||
contact: Contact | None = None
|
||||
if public_key:
|
||||
logger.debug("Path update for %s", public_key[:12])
|
||||
contact = await ContactRepository.get_by_key(public_key)
|
||||
elif pubkey_prefix:
|
||||
# Legacy compatibility: older payloads may only include a prefix.
|
||||
logger.debug("Path update for prefix %s", pubkey_prefix)
|
||||
contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
else:
|
||||
logger.debug("PATH_UPDATE missing public_key/pubkey_prefix, skipping")
|
||||
return
|
||||
|
||||
existing = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
if existing:
|
||||
await ContactRepository.update_path(existing.public_key, path, path_len)
|
||||
if not contact:
|
||||
return
|
||||
|
||||
# PATH_UPDATE is a serial control push event from firmware (not an RF packet).
|
||||
# Current meshcore payloads only include public_key for this event.
|
||||
# RF route/path bytes are handled via RX_LOG_DATA -> process_raw_packet,
|
||||
# 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]
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
normalized_path_len = int(path_len)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid path_len in PATH_UPDATE for %s: %r", contact.public_key[:12], path_len
|
||||
)
|
||||
return
|
||||
|
||||
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:
|
||||
normalized_path_hash_mode = None
|
||||
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_path(
|
||||
contact.public_key,
|
||||
str(path),
|
||||
normalized_path_len,
|
||||
normalized_path_hash_mode,
|
||||
)
|
||||
|
||||
|
||||
async def on_new_contact(event: "Event") -> None:
|
||||
@@ -215,13 +228,29 @@ async def on_new_contact(event: "Event") -> None:
|
||||
|
||||
logger.debug("New contact: %s", public_key[:12])
|
||||
|
||||
contact_data = {
|
||||
**Contact.from_radio_dict(public_key, 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=True)
|
||||
contact_upsert.last_seen = int(time.time())
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
|
||||
broadcast_event("contact", contact_data)
|
||||
adv_name = payload.get("adv_name")
|
||||
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(**contact_upsert.model_dump(exclude_none=True)).model_dump()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
@@ -235,14 +264,15 @@ async def on_ack(event: "Event") -> None:
|
||||
|
||||
logger.debug("Received ACK with code %s", ack_code)
|
||||
|
||||
_cleanup_expired_acks()
|
||||
cleanup_expired_acks()
|
||||
|
||||
if ack_code in _pending_acks:
|
||||
message_id, _, _ = _pending_acks.pop(ack_code)
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is not None:
|
||||
logger.info("ACK received for message %d", message_id)
|
||||
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
# 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.
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
|
||||
else:
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
107
app/events.py
Normal file
107
app/events.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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",
|
||||
"channel",
|
||||
"contact_deleted",
|
||||
"channel_deleted",
|
||||
"raw_packet",
|
||||
"message_acked",
|
||||
"error",
|
||||
"success",
|
||||
]
|
||||
|
||||
|
||||
class ContactDeletedPayload(TypedDict):
|
||||
public_key: str
|
||||
|
||||
|
||||
class ChannelDeletedPayload(TypedDict):
|
||||
key: str
|
||||
|
||||
|
||||
class MessageAckedPayload(TypedDict):
|
||||
message_id: int
|
||||
ack_count: int
|
||||
paths: NotRequired[list[MessagePath]]
|
||||
|
||||
|
||||
class ToastPayload(TypedDict):
|
||||
message: str
|
||||
details: NotRequired[str]
|
||||
|
||||
|
||||
WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
| RawPacketBroadcast
|
||||
| MessageAckedPayload
|
||||
| ToastPayload
|
||||
)
|
||||
|
||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
"contact": TypeAdapter(Contact),
|
||||
"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 validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
|
||||
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
return adapter.validate_python(data)
|
||||
|
||||
|
||||
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})
|
||||
|
||||
|
||||
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
|
||||
"""Return the JSON-serializable payload for a WebSocket event."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
|
||||
validated = adapter.validate_python(data)
|
||||
return adapter.dump_python(validated, mode="json")
|
||||
286
app/fanout/AGENTS_fanout.md
Normal file
286
app/fanout/AGENTS_fanout.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# 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
|
||||
|
||||
### 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
|
||||
|
||||
## 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", "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/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
|
||||
8
app/fanout/__init__.py
Normal file
8
app/fanout/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.manager import FanoutManager, fanout_manager
|
||||
|
||||
__all__ = [
|
||||
"FanoutManager",
|
||||
"FanoutModule",
|
||||
"fanout_manager",
|
||||
]
|
||||
130
app/fanout/apprise_mod.py
Normal file
130
app/fanout/apprise_mod.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""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
|
||||
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 = data.get("text", "")
|
||||
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)
|
||||
self._last_error: str | None = None
|
||||
|
||||
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._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._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"
|
||||
35
app/fanout/base.py
Normal file
35
app/fanout/base.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Base class for fanout integration modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
148
app/fanout/bot.py
Normal file
148
app/fanout/bot.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Fanout module wrapping bot execution logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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")
|
||||
# Message model serializes paths as list of dicts; extract first path string
|
||||
if path_value is None:
|
||||
paths = data.get("paths")
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
|
||||
# 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,
|
||||
),
|
||||
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"
|
||||
@@ -11,6 +11,7 @@ the security implications.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -48,15 +49,19 @@ def execute_bot_code(
|
||||
channel_name: str | None,
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
) -> 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)`
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
|
||||
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.
|
||||
|
||||
Args:
|
||||
code: Python code defining the bot function
|
||||
sender_name: Display name of the sender (may be None)
|
||||
@@ -67,6 +72,7 @@ def execute_bot_code(
|
||||
channel_name: Channel name (e.g. "#general" with hash), None for DMs
|
||||
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
|
||||
|
||||
Returns:
|
||||
Response string, list of strings, or None.
|
||||
@@ -84,8 +90,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
|
||||
@@ -95,18 +101,63 @@ def execute_bot_code(
|
||||
|
||||
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:
|
||||
# Call the bot function with message context
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
)
|
||||
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
|
||||
|
||||
try:
|
||||
# Call the bot function with appropriate signature
|
||||
if call_style == "positional":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
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,
|
||||
)
|
||||
else:
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
)
|
||||
|
||||
# Validate result
|
||||
if result is None:
|
||||
@@ -121,8 +172,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
|
||||
|
||||
|
||||
@@ -198,101 +249,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,
|
||||
),
|
||||
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)
|
||||
570
app/fanout/community_mqtt.py
Normal file
570
app/fanout/community_mqtt.py
Normal file
@@ -0,0 +1,570 @@
|
||||
"""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 importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
import aiomqtt
|
||||
import nacl.bindings
|
||||
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# Ed25519 group order
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
# 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 _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:
|
||||
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()
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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 a client version string like ``'RemoteTerm 2.4.0'``."""
|
||||
try:
|
||||
version = importlib.metadata.version("remoteterm-meshcore")
|
||||
return f"RemoteTerm {version}"
|
||||
except Exception:
|
||||
return "RemoteTerm unknown"
|
||||
|
||||
|
||||
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
|
||||
243
app/fanout/manager.py
Normal file
243
app/fanout/manager.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""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 _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.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
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
|
||||
|
||||
|
||||
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] = {}
|
||||
|
||||
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
|
||||
if config_type == "bot":
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
|
||||
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)
|
||||
logger.info(
|
||||
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to start fanout module %s", config_id)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
except asyncio.TimeoutError:
|
||||
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:
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
def get_statuses(self) -> dict[str, dict[str, str]]:
|
||||
"""Return status info for each active module."""
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for config_id, (module, _) in self._modules.items():
|
||||
info = _configs_cache.get(config_id, {})
|
||||
result[config_id] = {
|
||||
"name": info.get("name", config_id),
|
||||
"type": info.get("type", "unknown"),
|
||||
"status": module.status,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
fanout_manager = FanoutManager()
|
||||
91
app/fanout/mqtt.py
Normal file
91
app/fanout/mqtt.py
Normal file
@@ -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"
|
||||
239
app/fanout/mqtt_base.py
Normal file
239
app/fanout/mqtt_base.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Shared base class for MQTT publisher lifecycle management.
|
||||
|
||||
Both ``MqttPublisher`` (private broker) and ``CommunityMqttPublisher``
|
||||
(community aggregator) inherit from ``BaseMqttPublisher``, which owns
|
||||
the connection-loop skeleton, reconnect/backoff logic, and publish method.
|
||||
Subclasses override a small set of hooks to control configuration checks,
|
||||
client construction, toast messages, and optional wait-loop behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BACKOFF_MIN = 5
|
||||
|
||||
|
||||
def _broadcast_health() -> None:
|
||||
"""Push updated health (including MQTT status) to all WS 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 BaseMqttPublisher(ABC):
|
||||
"""Base class for MQTT publishers with shared lifecycle management.
|
||||
|
||||
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
|
||||
_log_prefix: str = "MQTT"
|
||||
_not_configured_timeout: float | None = None # None = block forever
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._settings: Any = None
|
||||
self._settings_version: int = 0
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
async def start(self, settings: object) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the background task and disconnect."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
|
||||
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], *, 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), retain=retain)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"%s publish failed on %s: %s",
|
||||
self._log_prefix,
|
||||
topic,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
|
||||
# ── Abstract hooks ─────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def _is_configured(self) -> bool:
|
||||
"""Return True when this publisher should attempt to connect."""
|
||||
|
||||
@abstractmethod
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
|
||||
|
||||
@abstractmethod
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
"""Return ``(title, detail)`` for the success toast on connect."""
|
||||
|
||||
@abstractmethod
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
"""Return ``(title, detail)`` for the error toast on connect failure."""
|
||||
|
||||
# ── Optional hooks ─────────────────────────────────────────────────
|
||||
|
||||
def _should_break_wait(self, elapsed: float) -> bool:
|
||||
"""Return True to break the inner wait (e.g. token expiry)."""
|
||||
return False
|
||||
|
||||
async def _pre_connect(self, settings: object) -> bool:
|
||||
"""Called before connecting. Return True to proceed, False to retry."""
|
||||
return True
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
"""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:
|
||||
"""Background loop: connect, wait for version change, reconnect on failure."""
|
||||
from app.websocket import broadcast_error, broadcast_success
|
||||
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
while True:
|
||||
if not self._is_configured():
|
||||
self._on_not_configured()
|
||||
self.connected = False
|
||||
self._client = None
|
||||
self._version_event.clear()
|
||||
try:
|
||||
if self._not_configured_timeout is None:
|
||||
await self._version_event.wait()
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
self._version_event.wait(),
|
||||
timeout=self._not_configured_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
continue
|
||||
|
||||
settings = self._settings
|
||||
assert settings is not None # guaranteed by _is_configured()
|
||||
version_at_connect = self._settings_version
|
||||
|
||||
try:
|
||||
if not await self._pre_connect(settings):
|
||||
continue
|
||||
|
||||
client_kwargs = self._build_client_kwargs(settings)
|
||||
connect_time = time.monotonic()
|
||||
|
||||
async with aiomqtt.Client(**client_kwargs) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
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.
|
||||
# The 60s timeout is a housekeeping wake-up; actual connection
|
||||
# liveness is handled by paho-mqtt's keepalive mechanism.
|
||||
while self._settings_version == version_at_connect:
|
||||
self._version_event.clear()
|
||||
try:
|
||||
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
|
||||
|
||||
# async with exited — client is now closed
|
||||
self._client = None
|
||||
self.connected = False
|
||||
_broadcast_health()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
logger.warning(
|
||||
"%s connection error: %s (reconnecting in %ds)",
|
||||
self._log_prefix,
|
||||
e,
|
||||
backoff,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
backoff = min(backoff * 2, self._backoff_max)
|
||||
138
app/fanout/mqtt_community.py
Normal file
138
app/fanout/mqtt_community.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""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()
|
||||
|
||||
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():
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
return "disconnected"
|
||||
|
||||
|
||||
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)
|
||||
61
app/fanout/mqtt_private.py
Normal file
61
app/fanout/mqtt_private.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""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()
|
||||
|
||||
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"
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
84
app/fanout/webhook.py
Normal file
84
app/fanout/webhook.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""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
|
||||
self._last_error: str | 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._last_error = None
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._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._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"
|
||||
@@ -1,13 +1,27 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
|
||||
if forwarded_proto and forwarded_host:
|
||||
proto = forwarded_proto.split(",")[0].strip()
|
||||
host = forwarded_host.split(",")[0].strip()
|
||||
if proto and host:
|
||||
return f"{proto}://{host}"
|
||||
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"""Register frontend static file routes if a built frontend is available.
|
||||
|
||||
@@ -55,6 +69,41 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"""Serve the frontend index.html."""
|
||||
return FileResponse(index_file)
|
||||
|
||||
@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)
|
||||
manifest = {
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"id": f"{origin}/",
|
||||
"start_url": f"{origin}/",
|
||||
"scope": f"{origin}/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
|
||||
"theme_color": "#111419",
|
||||
"background_color": "#111419",
|
||||
"icons": [
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
}
|
||||
return JSONResponse(
|
||||
manifest,
|
||||
media_type="application/manifest+json",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@app.get("/{path:path}")
|
||||
async def serve_frontend(path: str):
|
||||
"""Serve frontend files, falling back to index.html for SPA routing."""
|
||||
@@ -71,3 +120,16 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
|
||||
logger.info("Serving frontend from %s", frontend_dir)
|
||||
return True
|
||||
|
||||
|
||||
def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
"""Register a fallback route that tells the user to build the frontend."""
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def frontend_not_built():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -18,6 +18,13 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NO_EVENT_RECEIVED_GUIDANCE = (
|
||||
"Radio command channel is unresponsive (no_event_received). Ensure that your firmware is not "
|
||||
"incompatible, outdated, or wrong-mode (e.g. repeater, not client), and that"
|
||||
"serial/TCP/BLE connectivity is successful (try another app and see if that one works?). The app cannot proceed because it cannot "
|
||||
"issue commands to the radio."
|
||||
)
|
||||
|
||||
# In-memory storage for the private key and derived public key
|
||||
_private_key: bytes | None = None
|
||||
_public_key: bytes | None = None
|
||||
@@ -91,8 +98,14 @@ async def export_and_store_private_key(mc: "MeshCore") -> bool:
|
||||
)
|
||||
return False
|
||||
else:
|
||||
reason = result.payload.get("reason") if isinstance(result.payload, dict) else None
|
||||
if result.type == EventType.ERROR and reason == "no_event_received":
|
||||
logger.error("%s Raw response: %s", NO_EVENT_RECEIVED_GUIDANCE, result.payload)
|
||||
raise RuntimeError(NO_EVENT_RECEIVED_GUIDANCE)
|
||||
logger.error("Failed to export private key: %s", result.payload)
|
||||
return False
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error exporting private key: %s", e)
|
||||
return False
|
||||
|
||||
81
app/main.py
81
app/main.py
@@ -1,14 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_static_routes
|
||||
from app.radio import radio_manager
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.radio import RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
@@ -17,38 +19,72 @@ from app.radio_sync import (
|
||||
from app.routers import (
|
||||
channels,
|
||||
contacts,
|
||||
fanout,
|
||||
health,
|
||||
messages,
|
||||
packets,
|
||||
radio,
|
||||
read_state,
|
||||
repeaters,
|
||||
settings,
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
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."""
|
||||
await db.connect()
|
||||
logger.info("Database connected")
|
||||
|
||||
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)
|
||||
# Ensure default channels exist in the database even before the radio
|
||||
# connects. Without this, a fresh or disconnected instance would return
|
||||
# zero channels from GET /channels until the first successful radio sync.
|
||||
from app.radio_sync import ensure_default_channels
|
||||
|
||||
await ensure_default_channels()
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
|
||||
# Start fanout modules (MQTT, etc.) from database configs
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
try:
|
||||
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")
|
||||
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_message_polling()
|
||||
await stop_periodic_advert()
|
||||
@@ -59,10 +95,22 @@ async def lifespan(app: FastAPI):
|
||||
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="1.9.2",
|
||||
version=_get_version(),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -74,17 +122,28 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RadioDisconnectedError)
|
||||
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
|
||||
"""Return 503 when a radio disconnect race occurs during an operation."""
|
||||
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
||||
|
||||
|
||||
# API routes - all prefixed with /api for production compatibility
|
||||
app.include_router(health.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(channels.router, prefix="/api")
|
||||
app.include_router(messages.router, prefix="/api")
|
||||
app.include_router(packets.router, prefix="/api")
|
||||
app.include_router(read_state.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
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"
|
||||
register_frontend_static_routes(app, FRONTEND_DIR)
|
||||
if not register_frontend_static_routes(app, FRONTEND_DIR):
|
||||
register_frontend_missing_fallback(app)
|
||||
|
||||
1538
app/migrations.py
1538
app/migrations.py
File diff suppressed because it is too large
Load Diff
441
app/models.py
441
app/models.py
@@ -2,14 +2,78 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route
|
||||
|
||||
|
||||
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
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: 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."""
|
||||
last_path, last_path_len, out_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,
|
||||
),
|
||||
)
|
||||
return cls(
|
||||
public_key=public_key,
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
flags=radio_data.get("flags", 0),
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_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
|
||||
out_path_hash_mode: int = 0
|
||||
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
|
||||
@@ -17,6 +81,23 @@ class Contact(BaseModel):
|
||||
on_radio: 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
|
||||
|
||||
def has_route_override(self) -> bool:
|
||||
return self.route_override_len is not None
|
||||
|
||||
def effective_route(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,
|
||||
)
|
||||
return normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
@@ -24,37 +105,32 @@ class Contact(BaseModel):
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
"""
|
||||
last_path, last_path_len, out_path_hash_mode = self.effective_route()
|
||||
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": last_path,
|
||||
"out_path_len": last_path_len,
|
||||
"out_path_hash_mode": out_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,
|
||||
}
|
||||
|
||||
def to_upsert(self, **changes) -> ContactUpsert:
|
||||
"""Convert the stored contact to the repository's write contract."""
|
||||
return ContactUpsert.from_contact(self, **changes)
|
||||
|
||||
@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,
|
||||
}
|
||||
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
|
||||
return ContactUpsert.from_radio_dict(
|
||||
public_key,
|
||||
radio_data,
|
||||
on_radio=on_radio,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
@@ -68,23 +144,136 @@ class CreateContactRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ContactRoutingOverrideRequest(BaseModel):
|
||||
"""Request to set, force, or clear a contact routing override."""
|
||||
|
||||
route: str = Field(
|
||||
description=(
|
||||
"Blank clears the override and resets learned routing to flood, "
|
||||
'"-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
|
||||
|
||||
|
||||
class ContactAdvertPath(BaseModel):
|
||||
"""A unique advert path observed for a contact."""
|
||||
|
||||
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 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")
|
||||
heard_count: int = Field(description="Number of times this unique path was heard")
|
||||
|
||||
|
||||
class ContactAdvertPathSummary(BaseModel):
|
||||
"""Recent unique advertisement paths for a single contact."""
|
||||
|
||||
public_key: str = Field(description="Contact public key (64-char hex)")
|
||||
paths: list[ContactAdvertPath] = Field(
|
||||
default_factory=list, description="Most recent unique advert paths"
|
||||
)
|
||||
|
||||
|
||||
class ContactNameHistory(BaseModel):
|
||||
"""A historical name used by a contact."""
|
||||
|
||||
name: str
|
||||
first_seen: int
|
||||
last_seen: int
|
||||
|
||||
|
||||
class ContactActiveRoom(BaseModel):
|
||||
"""A channel/room where a contact has been active."""
|
||||
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
message_count: int
|
||||
|
||||
|
||||
class NearestRepeater(BaseModel):
|
||||
"""A repeater that has relayed a contact's advertisements."""
|
||||
|
||||
public_key: str
|
||||
name: str | None = None
|
||||
path_len: int
|
||||
last_seen: int
|
||||
heard_count: int
|
||||
|
||||
|
||||
class ContactDetail(BaseModel):
|
||||
"""Comprehensive contact profile data."""
|
||||
|
||||
contact: Contact
|
||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
||||
dm_message_count: int = 0
|
||||
channel_message_count: int = 0
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
|
||||
advert_frequency: float | None = Field(
|
||||
default=None,
|
||||
description="Advert observations per hour (includes multi-path arrivals of same advert)",
|
||||
)
|
||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
key: str = Field(description="Channel key (32-char hex)")
|
||||
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)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
|
||||
|
||||
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 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)
|
||||
|
||||
|
||||
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)",
|
||||
)
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -99,8 +288,17 @@ 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
|
||||
|
||||
|
||||
class MessagesAroundResponse(BaseModel):
|
||||
messages: list[Message]
|
||||
has_older: bool
|
||||
has_newer: bool
|
||||
|
||||
|
||||
class RawPacketDecryptedInfo(BaseModel):
|
||||
@@ -108,6 +306,8 @@ class RawPacketDecryptedInfo(BaseModel):
|
||||
|
||||
channel_name: str | None = None
|
||||
sender: str | None = None
|
||||
channel_key: str | None = None
|
||||
contact_key: str | None = None
|
||||
|
||||
|
||||
class RawPacketBroadcast(BaseModel):
|
||||
@@ -118,6 +318,11 @@ class RawPacketBroadcast(BaseModel):
|
||||
"""
|
||||
|
||||
id: int
|
||||
observation_id: int = Field(
|
||||
description=(
|
||||
"Monotonic per-process ID for this RF observation (distinct from the DB packet row ID)"
|
||||
)
|
||||
)
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
payload_type: str = Field(description="Packet type name (e.g., GROUP_TEXT, ADVERT)")
|
||||
@@ -141,12 +346,87 @@ class SendChannelMessageRequest(SendMessageRequest):
|
||||
channel_key: str = Field(description="Channel key (32-char hex)")
|
||||
|
||||
|
||||
class TelemetryRequest(BaseModel):
|
||||
class RepeaterLoginRequest(BaseModel):
|
||||
"""Request to log in to a repeater."""
|
||||
|
||||
password: str = Field(
|
||||
default="", description="Repeater password (empty string for no password)"
|
||||
default="", description="Repeater password (empty string for guest login)"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterLoginResponse(BaseModel):
|
||||
"""Response from repeater login."""
|
||||
|
||||
status: str = Field(description="Login result status")
|
||||
|
||||
|
||||
class RepeaterStatusResponse(BaseModel):
|
||||
"""Status telemetry from a repeater (single attempt, no retries)."""
|
||||
|
||||
battery_volts: float = Field(description="Battery voltage in volts")
|
||||
tx_queue_len: int = Field(description="Transmit queue length")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
last_rssi_dbm: int = Field(description="Last RSSI in dBm")
|
||||
last_snr_db: float = Field(description="Last SNR in dB")
|
||||
packets_received: int = Field(description="Total packets received")
|
||||
packets_sent: int = Field(description="Total packets sent")
|
||||
airtime_seconds: int = Field(description="TX airtime in seconds")
|
||||
rx_airtime_seconds: int = Field(description="RX airtime in seconds")
|
||||
uptime_seconds: int = Field(description="Uptime in seconds")
|
||||
sent_flood: int = Field(description="Flood packets sent")
|
||||
sent_direct: int = Field(description="Direct packets sent")
|
||||
recv_flood: int = Field(description="Flood packets received")
|
||||
recv_direct: int = Field(description="Direct packets received")
|
||||
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")
|
||||
|
||||
|
||||
class RepeaterRadioSettingsResponse(BaseModel):
|
||||
"""Radio settings from a repeater (batch CLI get commands)."""
|
||||
|
||||
firmware_version: str | None = Field(default=None, description="Firmware version string")
|
||||
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
|
||||
tx_power: str | None = Field(default=None, description="TX power in dBm")
|
||||
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):
|
||||
"""Advertisement intervals from a repeater."""
|
||||
|
||||
advert_interval: str | None = Field(default=None, description="Local advert interval")
|
||||
flood_advert_interval: str | None = Field(default=None, description="Flood advert interval")
|
||||
|
||||
|
||||
class RepeaterOwnerInfoResponse(BaseModel):
|
||||
"""Owner info and guest password from a repeater."""
|
||||
|
||||
owner_info: str | None = Field(default=None, description="Owner info string")
|
||||
guest_password: str | None = Field(default=None, description="Guest password")
|
||||
|
||||
|
||||
class LppSensor(BaseModel):
|
||||
"""A single CayenneLPP sensor reading from req_telemetry_sync."""
|
||||
|
||||
channel: int = Field(description="LPP channel number")
|
||||
type_name: str = Field(description="Sensor type name (e.g. temperature, humidity)")
|
||||
value: float | dict = Field(
|
||||
description="Scalar value or dict for multi-value sensors (GPS, accel)"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterLppTelemetryResponse(BaseModel):
|
||||
"""CayenneLPP sensor telemetry from a repeater."""
|
||||
|
||||
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
|
||||
|
||||
|
||||
class NeighborInfo(BaseModel):
|
||||
"""Information about a neighbor seen by a repeater."""
|
||||
|
||||
@@ -167,34 +447,18 @@ class AclEntry(BaseModel):
|
||||
permission_name: str = Field(description="Human-readable permission name")
|
||||
|
||||
|
||||
class TelemetryResponse(BaseModel):
|
||||
"""Telemetry data from a repeater, formatted for human readability."""
|
||||
class RepeaterNeighborsResponse(BaseModel):
|
||||
"""Neighbors list from a repeater."""
|
||||
|
||||
pubkey_prefix: str = Field(description="12-char public key prefix")
|
||||
battery_volts: float = Field(description="Battery voltage in volts")
|
||||
tx_queue_len: int = Field(description="Transmit queue length")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
last_rssi_dbm: int = Field(description="Last RSSI in dBm")
|
||||
last_snr_db: float = Field(description="Last SNR in dB")
|
||||
packets_received: int = Field(description="Total packets received")
|
||||
packets_sent: int = Field(description="Total packets sent")
|
||||
airtime_seconds: int = Field(description="TX airtime in seconds")
|
||||
rx_airtime_seconds: int = Field(description="RX airtime in seconds")
|
||||
uptime_seconds: int = Field(description="Uptime in seconds")
|
||||
sent_flood: int = Field(description="Flood packets sent")
|
||||
sent_direct: int = Field(description="Direct packets sent")
|
||||
recv_flood: int = Field(description="Flood packets received")
|
||||
recv_direct: int = Field(description="Direct packets received")
|
||||
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")
|
||||
neighbors: list[NeighborInfo] = Field(
|
||||
default_factory=list, description="List of neighbors seen by repeater"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterAclResponse(BaseModel):
|
||||
"""ACL list from a repeater."""
|
||||
|
||||
acl: list[AclEntry] = Field(default_factory=list, description="Access control list")
|
||||
clock_output: str | None = Field(
|
||||
default=None, description="Output from 'clock' command (or error message)"
|
||||
)
|
||||
|
||||
|
||||
class TraceResponse(BaseModel):
|
||||
@@ -232,15 +496,6 @@ class Favorite(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class BotConfig(BaseModel):
|
||||
"""Configuration for a single bot."""
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class UnreadCounts(BaseModel):
|
||||
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
|
||||
|
||||
@@ -261,15 +516,8 @@ 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)"
|
||||
),
|
||||
)
|
||||
experimental_channel_double_send: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Experimental: when enabled, channel messages are sent twice with a 3-second delay, "
|
||||
"reusing the same timestamp bytes"
|
||||
"Configured radio contact capacity used for maintenance thresholds; "
|
||||
"favorites reload first, then background fill targets about 80% of this value"
|
||||
),
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
@@ -299,7 +547,66 @@ class AppSettings(BaseModel):
|
||||
default=0,
|
||||
description="Unix timestamp of last advertisement sent (0 = never)",
|
||||
)
|
||||
bots: list[BotConfig] = Field(
|
||||
default_factory=list,
|
||||
description="List of bot configurations",
|
||||
flood_scope: str = Field(
|
||||
default="",
|
||||
description="Outbound flood scope / region name (empty = disabled, no tagging)",
|
||||
)
|
||||
blocked_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
)
|
||||
blocked_names: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
|
||||
|
||||
class FanoutConfig(BaseModel):
|
||||
"""Configuration for a single fanout integration."""
|
||||
|
||||
id: str
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
|
||||
name: str
|
||||
enabled: bool
|
||||
config: dict
|
||||
scope: dict
|
||||
sort_order: int = 0
|
||||
created_at: int = 0
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
message_count: int
|
||||
|
||||
|
||||
class ContactActivityCounts(BaseModel):
|
||||
last_hour: int
|
||||
last_24_hours: int
|
||||
last_week: int
|
||||
|
||||
|
||||
class PathHashWidthStats(BaseModel):
|
||||
total_packets: int
|
||||
single_byte: int
|
||||
double_byte: int
|
||||
triple_byte: int
|
||||
single_byte_pct: float
|
||||
double_byte_pct: float
|
||||
triple_byte_pct: float
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
repeater_count: int
|
||||
channel_count: int
|
||||
total_packets: int
|
||||
decrypted_packets: int
|
||||
undecrypted_packets: int
|
||||
total_dms: int
|
||||
total_channel_messages: int
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
|
||||
@@ -15,6 +15,7 @@ are offloaded from the radio to the server.
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from itertools import count
|
||||
|
||||
from app.decoder import (
|
||||
DecryptedDirectMessage,
|
||||
@@ -27,81 +28,30 @@ from app.decoder import (
|
||||
try_decrypt_packet_with_channel_key,
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactUpsert,
|
||||
RawPacketBroadcast,
|
||||
RawPacketDecryptedInfo,
|
||||
)
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import record_contact_name_and_reconcile
|
||||
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__)
|
||||
|
||||
|
||||
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 = await MessageRepository.get_ack_count(existing_msg.id)
|
||||
|
||||
# Broadcast updated paths
|
||||
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)
|
||||
_raw_observation_counter = count(1)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
@@ -112,102 +62,25 @@ async def create_message_from_decrypted(
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | 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()
|
||||
|
||||
# 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,
|
||||
path_len=path_len,
|
||||
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 = [{"path": path or "", "received_at": received}] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "CHAN",
|
||||
"conversation_key": channel_key_normalized,
|
||||
"text": text,
|
||||
"sender_timestamp": timestamp,
|
||||
"received_at": received,
|
||||
"paths": paths,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": False,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# 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,
|
||||
@@ -216,124 +89,24 @@ 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,
|
||||
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,
|
||||
outgoing=outgoing,
|
||||
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 = [{"path": path or "", "received_at": received}] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "PRIV",
|
||||
"conversation_key": conversation_key,
|
||||
"text": decrypted.message,
|
||||
"sender_timestamp": decrypted.timestamp,
|
||||
"received_at": received,
|
||||
"paths": paths,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": outgoing,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# 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,
|
||||
@@ -358,11 +131,14 @@ async def run_historical_dm_decryption(
|
||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
# Note: passing our_public_key=None means outgoing DMs won't be matched
|
||||
# by try_decrypt_dm (the inbound check requires src_hash == their_first_byte,
|
||||
# which fails for our outgoing packets). This is acceptable because outgoing
|
||||
# DMs are stored directly by the send endpoint. Historical decryption only
|
||||
# recovers incoming messages.
|
||||
# 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,
|
||||
# outgoing packets fail the inbound check and are skipped — which is correct
|
||||
# since outgoing DMs are stored directly by the send endpoint.
|
||||
# For the 1/256 case where bytes match, an outgoing packet may decrypt
|
||||
# successfully, but the dual-hash direction check below correctly identifies
|
||||
# it and the DB dedup constraint prevents a duplicate insert.
|
||||
result = try_decrypt_dm(
|
||||
packet_data,
|
||||
private_key_bytes,
|
||||
@@ -371,14 +147,25 @@ async def run_historical_dm_decryption(
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Determine direction by checking src_hash
|
||||
# Determine direction using both hashes (mirrors _process_direct_message
|
||||
# logic at lines 806-818) to handle the 1/256 case where our first
|
||||
# public key byte matches the contact's.
|
||||
src_hash = result.src_hash.lower()
|
||||
dest_hash = result.dest_hash.lower()
|
||||
our_first_byte = format(our_public_key_bytes[0], "02x").lower()
|
||||
outgoing = src_hash == our_first_byte
|
||||
|
||||
if src_hash == our_first_byte and dest_hash != our_first_byte:
|
||||
outgoing = True
|
||||
else:
|
||||
# Incoming, ambiguous (both match), or neither matches.
|
||||
# Default to incoming — outgoing DMs are stored by the send
|
||||
# endpoint, so historical decryption only recovers incoming.
|
||||
outgoing = False
|
||||
|
||||
# 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,
|
||||
@@ -387,8 +174,9 @@ 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:
|
||||
@@ -476,6 +264,7 @@ async def process_raw_packet(
|
||||
since the original packet was already processed.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
observation_id = next(_raw_observation_counter)
|
||||
|
||||
packet_id, is_new_packet = await RawPacketRepository.create(raw_bytes, ts)
|
||||
raw_hex = raw_bytes.hex()
|
||||
@@ -485,6 +274,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(
|
||||
@@ -517,8 +313,10 @@ async def process_raw_packet(
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
elif payload_type == PayloadType.ADVERT and is_new_packet:
|
||||
# Only process new advertisements (duplicates don't add value)
|
||||
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.
|
||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
@@ -531,6 +329,7 @@ async def process_raw_packet(
|
||||
# This enables the frontend cracker to see all incoming packets in real-time
|
||||
broadcast_payload = RawPacketBroadcast(
|
||||
id=packet_id,
|
||||
observation_id=observation_id,
|
||||
timestamp=ts,
|
||||
data=raw_hex,
|
||||
payload_type=payload_type_name,
|
||||
@@ -540,6 +339,8 @@ async def process_raw_packet(
|
||||
decrypted_info=RawPacketDecryptedInfo(
|
||||
channel_name=result["channel_name"],
|
||||
sender=result["sender"],
|
||||
channel_key=result.get("channel_key"),
|
||||
contact_key=result.get("contact_key"),
|
||||
)
|
||||
if result["decrypted"]
|
||||
else None,
|
||||
@@ -589,6 +390,7 @@ 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,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -596,6 +398,7 @@ async def _process_group_text(
|
||||
"channel_name": channel.name,
|
||||
"sender": decrypted.sender,
|
||||
"message_id": msg_id, # None if duplicate, msg_id if new
|
||||
"channel_key": channel.key,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known key
|
||||
@@ -611,7 +414,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:
|
||||
@@ -656,9 +458,11 @@ async def _process_advertisement(
|
||||
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 ""
|
||||
out_path_hash_mode = existing.out_path_hash_mode
|
||||
else:
|
||||
path_len = new_path_len
|
||||
path_hex = new_path_hex
|
||||
out_path_hash_mode = packet_info.path_hash_size - 1
|
||||
|
||||
logger.debug(
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
|
||||
@@ -676,45 +480,48 @@ async def _process_advertisement(
|
||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
# Broadcast contact update to connected clients
|
||||
broadcast_event(
|
||||
"contact",
|
||||
{
|
||||
"public_key": advert.public_key.lower(),
|
||||
"name": advert.name,
|
||||
"type": contact_type,
|
||||
"flags": existing.flags if existing else 0,
|
||||
"last_path": path_hex,
|
||||
"last_path_len": path_len,
|
||||
"last_advert": advert.timestamp if advert.timestamp > 0 else timestamp,
|
||||
"lat": advert.lat,
|
||||
"lon": advert.lon,
|
||||
"last_seen": timestamp,
|
||||
"on_radio": existing.on_radio if existing else False,
|
||||
},
|
||||
# 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,
|
||||
)
|
||||
|
||||
contact_upsert = ContactUpsert(
|
||||
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,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=advert.public_key,
|
||||
contact_name=advert.name,
|
||||
timestamp=timestamp,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# 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())
|
||||
else:
|
||||
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
|
||||
if existing is None:
|
||||
@@ -724,14 +531,6 @@ 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,
|
||||
@@ -836,7 +635,8 @@ 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,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
@@ -845,6 +645,7 @@ async def _process_direct_message(
|
||||
"contact_name": contact.name,
|
||||
"sender": contact.name or contact.public_key[:12],
|
||||
"message_id": msg_id,
|
||||
"contact_key": contact.public_key,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known contact
|
||||
|
||||
246
app/path_utils.py
Normal file
246
app/path_utils.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
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,
|
||||
out_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 `last_path_len` (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(out_path_hash_mode) if out_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,
|
||||
out_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,
|
||||
out_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
|
||||
181
app/radio.py
181
app/radio.py
@@ -2,7 +2,7 @@ import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import platform
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore import MeshCore
|
||||
@@ -20,10 +20,8 @@ class RadioOperationBusyError(RadioOperationError):
|
||||
"""Raised when a non-blocking radio operation cannot acquire the lock."""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_context():
|
||||
"""No-op async context manager for optional nesting."""
|
||||
yield
|
||||
class RadioDisconnectedError(RadioOperationError):
|
||||
"""Raised when the radio disconnects between pre-check and lock acquisition."""
|
||||
|
||||
|
||||
def detect_serial_devices() -> list[str]:
|
||||
@@ -70,6 +68,7 @@ def detect_serial_devices() -> list[str]:
|
||||
|
||||
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
|
||||
try:
|
||||
logger.debug("Testing serial device %s", port)
|
||||
mc = await asyncio.wait_for(
|
||||
@@ -80,10 +79,8 @@ async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) ->
|
||||
# Check if we got valid self_info (indicates successful communication)
|
||||
if mc.is_connected and mc.self_info:
|
||||
logger.debug("Device %s responded with valid self_info", port)
|
||||
await mc.disconnect()
|
||||
return True
|
||||
|
||||
await mc.disconnect()
|
||||
return False
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Device %s timed out", port)
|
||||
@@ -91,6 +88,12 @@ async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) ->
|
||||
except Exception as e:
|
||||
logger.debug("Device %s failed: %s", port, e)
|
||||
return False
|
||||
finally:
|
||||
if mc is not None:
|
||||
try:
|
||||
await mc.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def find_radio_port(baudrate: int) -> str | None:
|
||||
@@ -124,6 +127,9 @@ class RadioManager:
|
||||
self._operation_lock: asyncio.Lock | None = None
|
||||
self._setup_lock: asyncio.Lock | None = None
|
||||
self._setup_in_progress: bool = False
|
||||
self._setup_complete: bool = False
|
||||
self.path_hash_mode: int = 0
|
||||
self.path_hash_mode_supported: bool = False
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
@@ -161,37 +167,48 @@ class RadioManager:
|
||||
pause_polling: bool = False,
|
||||
suspend_auto_fetch: bool = False,
|
||||
blocking: bool = True,
|
||||
meshcore: MeshCore | None = None,
|
||||
):
|
||||
"""Acquire shared radio lock and optionally pause polling / auto-fetch.
|
||||
|
||||
After acquiring the lock, resolves the current MeshCore instance and
|
||||
yields it. Callers get a fresh reference via ``async with ... as mc:``,
|
||||
avoiding stale-reference bugs when a reconnect swaps ``_meshcore``
|
||||
between the pre-check and the lock acquisition.
|
||||
|
||||
Args:
|
||||
name: Human-readable operation name for logs/errors.
|
||||
pause_polling: Pause fallback message polling while held.
|
||||
suspend_auto_fetch: Stop MeshCore auto message fetching while held.
|
||||
blocking: If False, fail immediately when lock is held.
|
||||
meshcore: Optional explicit MeshCore instance for auto-fetch control.
|
||||
|
||||
Raises:
|
||||
RadioDisconnectedError: If the radio disconnected before the lock
|
||||
was acquired (``_meshcore`` is ``None``).
|
||||
"""
|
||||
await self._acquire_operation_lock(name, blocking=blocking)
|
||||
|
||||
poll_context = _noop_context()
|
||||
mc = self._meshcore
|
||||
if mc is None:
|
||||
self._release_operation_lock(name)
|
||||
raise RadioDisconnectedError("Radio disconnected")
|
||||
|
||||
poll_context = nullcontext()
|
||||
if pause_polling:
|
||||
from app.radio_sync import pause_polling as pause_polling_context
|
||||
|
||||
poll_context = pause_polling_context()
|
||||
|
||||
mc = meshcore or self._meshcore
|
||||
auto_fetch_paused = False
|
||||
|
||||
try:
|
||||
async with poll_context:
|
||||
if suspend_auto_fetch and mc is not None:
|
||||
if suspend_auto_fetch:
|
||||
await mc.stop_auto_message_fetching()
|
||||
auto_fetch_paused = True
|
||||
yield
|
||||
yield mc
|
||||
finally:
|
||||
try:
|
||||
if auto_fetch_paused and mc is not None:
|
||||
if auto_fetch_paused:
|
||||
try:
|
||||
await mc.start_auto_message_fetching()
|
||||
except Exception as e:
|
||||
@@ -200,71 +217,10 @@ 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).
|
||||
"""
|
||||
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._meshcore:
|
||||
return
|
||||
|
||||
if self._setup_lock is None:
|
||||
self._setup_lock = asyncio.Lock()
|
||||
|
||||
async with self._setup_lock:
|
||||
if not self._meshcore:
|
||||
return
|
||||
self._setup_in_progress = True
|
||||
try:
|
||||
register_event_handlers(self._meshcore)
|
||||
await export_and_store_private_key(self._meshcore)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time()
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all()
|
||||
logger.info("Sync complete: %s", result)
|
||||
|
||||
# Start periodic sync (idempotent)
|
||||
start_periodic_sync()
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement():
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Start periodic advertisement (idempotent)
|
||||
start_periodic_advert()
|
||||
|
||||
await self._meshcore.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
|
||||
# Drain any messages that were queued before we connected
|
||||
drained = await drain_pending_messages()
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
|
||||
# Start periodic message polling as fallback (idempotent)
|
||||
start_message_polling()
|
||||
finally:
|
||||
self._setup_in_progress = False
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
await run_post_connect_setup(self)
|
||||
|
||||
@property
|
||||
def meshcore(self) -> MeshCore | None:
|
||||
@@ -286,6 +242,10 @@ class RadioManager:
|
||||
def is_setup_in_progress(self) -> bool:
|
||||
return self._setup_in_progress
|
||||
|
||||
@property
|
||||
def is_setup_complete(self) -> bool:
|
||||
return self._setup_complete
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio using the configured transport."""
|
||||
if self._meshcore is not None:
|
||||
@@ -323,6 +283,7 @@ class RadioManager:
|
||||
)
|
||||
self._connection_info = f"Serial: {port}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
logger.debug("Serial connection established")
|
||||
|
||||
async def _connect_tcp(self) -> None:
|
||||
@@ -339,6 +300,7 @@ class RadioManager:
|
||||
)
|
||||
self._connection_info = f"TCP: {host}:{port}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
logger.debug("TCP connection established")
|
||||
|
||||
async def _connect_ble(self) -> None:
|
||||
@@ -355,6 +317,7 @@ class RadioManager:
|
||||
)
|
||||
self._connection_info = f"BLE: {address}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
logger.debug("BLE connection established")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
@@ -363,6 +326,9 @@ class RadioManager:
|
||||
logger.debug("Disconnecting from radio")
|
||||
await self._meshcore.disconnect()
|
||||
self._meshcore = None
|
||||
self._setup_complete = False
|
||||
self.path_hash_mode = 0
|
||||
self.path_hash_mode_supported = False
|
||||
logger.debug("Radio disconnected")
|
||||
|
||||
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
@@ -377,12 +343,13 @@ class RadioManager:
|
||||
if self._reconnect_lock is None:
|
||||
self._reconnect_lock = asyncio.Lock()
|
||||
|
||||
# Try to acquire lock without blocking to check if reconnect is in progress
|
||||
if self._reconnect_lock.locked():
|
||||
logger.debug("Reconnection already in progress")
|
||||
return False
|
||||
|
||||
async with self._reconnect_lock:
|
||||
# If we became connected while waiting for the lock (another
|
||||
# reconnect succeeded ahead of us), skip the redundant attempt.
|
||||
if self.is_connected:
|
||||
logger.debug("Already connected after acquiring lock, skipping reconnect")
|
||||
return True
|
||||
|
||||
logger.info("Attempting to reconnect to radio...")
|
||||
|
||||
try:
|
||||
@@ -407,58 +374,18 @@ class RadioManager:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Reconnection failed: %s", e)
|
||||
logger.warning("Reconnection failed: %s", e, exc_info=True)
|
||||
broadcast_error("Reconnection failed", str(e))
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -4,35 +4,108 @@ Radio sync and offload management.
|
||||
This module handles syncing contacts and channels from the radio to the database,
|
||||
then removing them from the radio to free up space for new discoveries.
|
||||
|
||||
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
|
||||
Also handles loading favorites plus recently active contacts TO the radio for DM ACK support.
|
||||
Also handles periodic message polling as a fallback for platforms where push events
|
||||
don't work reliably.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from meshcore import EventType
|
||||
from meshcore import EventType, MeshCore
|
||||
|
||||
from app.models import Contact
|
||||
from app.radio import RadioOperationBusyError, radio_manager
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"""Return key contact fields for sync failure diagnostics."""
|
||||
return {
|
||||
"type": contact.type,
|
||||
"flags": contact.flags,
|
||||
"last_path": contact.last_path,
|
||||
"last_path_len": contact.last_path_len,
|
||||
"out_path_hash_mode": contact.out_path_hash_mode,
|
||||
"route_override_path": contact.route_override_path,
|
||||
"route_override_len": contact.route_override_len,
|
||||
"route_override_hash_mode": contact.route_override_hash_mode,
|
||||
"last_advert": contact.last_advert,
|
||||
"lat": contact.lat,
|
||||
"lon": contact.lon,
|
||||
"on_radio": contact.on_radio,
|
||||
}
|
||||
|
||||
|
||||
async def _reconcile_contact_messages_background(
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
) -> None:
|
||||
"""Run contact/message reconciliation outside the radio critical path."""
|
||||
try:
|
||||
await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
log=logger,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Background contact reconciliation failed for %s: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> str | None:
|
||||
"""Parse a radio channel-slot payload and upsert to the database.
|
||||
|
||||
Returns the uppercase hex key if a channel was upserted, or None if the
|
||||
slot was empty/invalid.
|
||||
"""
|
||||
name = payload.get("channel_name", "")
|
||||
secret = payload.get("channel_secret", b"")
|
||||
|
||||
# Skip empty channels
|
||||
if not name or name == "\x00" * len(name):
|
||||
return None
|
||||
|
||||
is_hashtag = name.startswith("#")
|
||||
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
|
||||
key_hex = key_bytes.hex().upper()
|
||||
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=on_radio,
|
||||
)
|
||||
return key_hex
|
||||
|
||||
|
||||
# Message poll task handle
|
||||
_message_poll_task: asyncio.Task | None = None
|
||||
|
||||
# Message poll interval in seconds
|
||||
MESSAGE_POLL_INTERVAL = 5
|
||||
# Message poll interval in seconds when aggressive fallback is enabled.
|
||||
MESSAGE_POLL_INTERVAL = 10
|
||||
|
||||
# Always-on audit interval when aggressive fallback is disabled.
|
||||
MESSAGE_POLL_AUDIT_INTERVAL = 3600
|
||||
|
||||
# Periodic advertisement task handle
|
||||
_advert_task: asyncio.Task | None = None
|
||||
@@ -41,6 +114,11 @@ _advert_task: asyncio.Task | None = None
|
||||
# We still need to periodically check if it's been enabled
|
||||
ADVERT_CHECK_INTERVAL = 60
|
||||
|
||||
# Minimum allowed advertisement interval (1 hour).
|
||||
# Even if the database has a shorter value, we silently refuse to advertise
|
||||
# more frequently than this.
|
||||
MIN_ADVERT_INTERVAL = 3600
|
||||
|
||||
# Counter to pause polling during repeater operations (supports nested pauses)
|
||||
_polling_pause_count: int = 0
|
||||
|
||||
@@ -67,20 +145,64 @@ async def pause_polling():
|
||||
# Background task handle
|
||||
_sync_task: asyncio.Task | None = None
|
||||
|
||||
# Sync interval in seconds (5 minutes)
|
||||
# Periodic maintenance check interval in seconds (5 minutes)
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
# Reload non-favorite contacts up to 80% of configured radio capacity after offload.
|
||||
RADIO_CONTACT_REFILL_RATIO = 0.80
|
||||
|
||||
async def sync_and_offload_contacts() -> dict:
|
||||
# Trigger a full offload/reload once occupancy reaches 95% of configured capacity.
|
||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||
|
||||
|
||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||
capacity = max(1, max_contacts)
|
||||
refill_target = max(1, min(capacity, int((capacity * RADIO_CONTACT_REFILL_RATIO) + 0.5)))
|
||||
full_sync_trigger = max(
|
||||
refill_target,
|
||||
min(capacity, math.ceil(capacity * RADIO_CONTACT_FULL_SYNC_RATIO)),
|
||||
)
|
||||
return refill_target, full_sync_trigger
|
||||
|
||||
|
||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
capacity = app_settings.max_radio_contacts
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||
return False
|
||||
|
||||
current_contacts = len(result.payload or {})
|
||||
if current_contacts >= full_sync_trigger:
|
||||
logger.info(
|
||||
"Running full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Skipping full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Sync contacts from radio to database, then remove them from radio.
|
||||
Returns counts of synced and removed contacts.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.warning("Cannot sync contacts: radio not connected")
|
||||
return {"synced": 0, "removed": 0, "error": "Radio not connected"}
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
synced = 0
|
||||
removed = 0
|
||||
|
||||
@@ -89,7 +211,16 @@ async def sync_and_offload_contacts() -> dict:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.error("Failed to get contacts from radio: %s", result)
|
||||
logger.error(
|
||||
"Failed to get contacts from radio: %s. "
|
||||
"If you see this repeatedly, the radio may be visible on the "
|
||||
"serial/TCP/BLE port but not responding to commands. Check for "
|
||||
"another process with the serial port open (other RemoteTerm "
|
||||
"instances, serial monitors, etc.), verify the firmware is "
|
||||
"up-to-date and in client mode (not repeater), or try a "
|
||||
"power cycle.",
|
||||
result,
|
||||
)
|
||||
return {"synced": 0, "removed": 0, "error": str(result)}
|
||||
|
||||
contacts = result.payload or {}
|
||||
@@ -99,15 +230,14 @@ async def sync_and_offload_contacts() -> dict:
|
||||
for public_key, contact_data in contacts.items():
|
||||
# Save to database
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
)
|
||||
claimed = await MessageRepository.claim_prefix_messages(public_key.lower())
|
||||
if claimed > 0:
|
||||
logger.info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
public_key[:12],
|
||||
asyncio.create_task(
|
||||
_reconcile_contact_messages_background(
|
||||
public_key,
|
||||
contact_data.get("adv_name"),
|
||||
)
|
||||
)
|
||||
synced += 1
|
||||
|
||||
# Remove from radio
|
||||
@@ -115,6 +245,30 @@ async def sync_and_offload_contacts() -> dict:
|
||||
remove_result = await mc.commands.remove_contact(contact_data)
|
||||
if remove_result.type == EventType.OK:
|
||||
removed += 1
|
||||
|
||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's
|
||||
# commands.remove_contact() sends the remove command over
|
||||
# the wire but does NOT update the library's in-memory
|
||||
# contact cache (mc._contacts). This is a gap in the
|
||||
# library — there's no public API to clear a single
|
||||
# contact from the cache, and the library only refreshes
|
||||
# it on a full get_contacts() call.
|
||||
#
|
||||
# Why this matters: sync_recent_contacts_to_radio() uses
|
||||
# mc.get_contact_by_key_prefix() to check whether a
|
||||
# contact is already loaded on the radio. That method
|
||||
# searches mc._contacts. If we don't evict the removed
|
||||
# contact from the cache here, get_contact_by_key_prefix()
|
||||
# will still find it and skip the add_contact() call —
|
||||
# meaning contacts never get loaded back onto the radio
|
||||
# after offload. The result: no DM ACKs, degraded routing
|
||||
# for potentially minutes until the next periodic sync
|
||||
# refreshes the cache from the (now-empty) radio.
|
||||
#
|
||||
# We access mc._contacts directly because the library
|
||||
# exposes it as a read-only property (mc.contacts) with
|
||||
# no removal API. The dict is keyed by public_key string.
|
||||
mc._contacts.pop(public_key, None)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||
@@ -131,16 +285,11 @@ async def sync_and_offload_contacts() -> dict:
|
||||
return {"synced": synced, "removed": removed}
|
||||
|
||||
|
||||
async def sync_and_offload_channels() -> dict:
|
||||
async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Sync channels from radio to database, then clear them from radio.
|
||||
Returns counts of synced and cleared channels.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.warning("Cannot sync channels: radio not connected")
|
||||
return {"synced": 0, "cleared": 0, "error": "Radio not connected"}
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
synced = 0
|
||||
cleared = 0
|
||||
|
||||
@@ -152,29 +301,15 @@ async def sync_and_offload_channels() -> dict:
|
||||
if result.type != EventType.CHANNEL_INFO:
|
||||
continue
|
||||
|
||||
payload = result.payload
|
||||
name = payload.get("channel_name", "")
|
||||
secret = payload.get("channel_secret", b"")
|
||||
|
||||
# Skip empty channels
|
||||
if not name or name == "\x00" * len(name) or all(b == 0 for b in secret):
|
||||
continue
|
||||
|
||||
is_hashtag = name.startswith("#")
|
||||
|
||||
# Convert key bytes to hex string
|
||||
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
|
||||
key_hex = key_bytes.hex().upper()
|
||||
|
||||
# Save to database
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=name,
|
||||
is_hashtag=is_hashtag,
|
||||
key_hex = await upsert_channel_from_radio_slot(
|
||||
result.payload,
|
||||
on_radio=False, # We're about to clear it
|
||||
)
|
||||
if key_hex is None:
|
||||
continue
|
||||
|
||||
synced += 1
|
||||
logger.debug("Synced channel %s: %s", key_hex[:8], name)
|
||||
logger.debug("Synced channel %s: %s", key_hex[:8], result.payload.get("channel_name"))
|
||||
|
||||
# Clear from radio (set empty name and zero key)
|
||||
try:
|
||||
@@ -221,33 +356,36 @@ async def ensure_default_channels() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def sync_and_offload_all() -> dict:
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
contacts_result = await sync_and_offload_contacts()
|
||||
channels_result = await sync_and_offload_channels()
|
||||
contacts_result = await sync_and_offload_contacts(mc)
|
||||
channels_result = await sync_and_offload_channels(mc)
|
||||
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately
|
||||
# so they do not stay in on_radio=False limbo after offload. Pass mc directly
|
||||
# since the caller already holds the radio operation lock (asyncio.Lock is not
|
||||
# reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
|
||||
return {
|
||||
"contacts": contacts_result,
|
||||
"channels": channels_result,
|
||||
"reloaded": reload_result,
|
||||
}
|
||||
|
||||
|
||||
async def drain_pending_messages() -> int:
|
||||
async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
"""
|
||||
Drain all pending messages from the radio.
|
||||
|
||||
Calls get_msg() repeatedly until NO_MORE_MSGS is received.
|
||||
Returns the count of messages retrieved.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
return 0
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
count = 0
|
||||
max_iterations = 100 # Safety limit
|
||||
|
||||
@@ -269,13 +407,13 @@ async def drain_pending_messages() -> int:
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Error draining messages: %s", e)
|
||||
logger.warning("Error draining messages: %s", e, exc_info=True)
|
||||
break
|
||||
|
||||
return count
|
||||
|
||||
|
||||
async def poll_for_messages() -> int:
|
||||
async def poll_for_messages(mc: MeshCore) -> int:
|
||||
"""
|
||||
Poll the radio for any pending messages (single pass).
|
||||
|
||||
@@ -284,10 +422,6 @@ async def poll_for_messages() -> int:
|
||||
|
||||
Returns the count of messages retrieved.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
return 0
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
count = 0
|
||||
|
||||
try:
|
||||
@@ -302,12 +436,12 @@ async def poll_for_messages() -> int:
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
count += 1
|
||||
# If we got a message, there might be more - drain them
|
||||
count += await drain_pending_messages()
|
||||
count += await drain_pending_messages(mc)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Message poll exception: %s", e)
|
||||
logger.warning("Message poll exception: %s", e, exc_info=True)
|
||||
|
||||
return count
|
||||
|
||||
@@ -316,24 +450,45 @@ async def _message_poll_loop():
|
||||
"""Background task that periodically polls for messages."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
|
||||
aggressive_fallback = settings.enable_message_poll_fallback
|
||||
await asyncio.sleep(
|
||||
MESSAGE_POLL_INTERVAL if aggressive_fallback else MESSAGE_POLL_AUDIT_INTERVAL
|
||||
)
|
||||
|
||||
if radio_manager.is_connected and not is_polling_paused():
|
||||
mc = radio_manager.meshcore
|
||||
if mc is not None:
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"message_poll_loop",
|
||||
blocking=False,
|
||||
):
|
||||
await poll_for_messages()
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"message_poll_loop",
|
||||
blocking=False,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
if count > 0:
|
||||
if aggressive_fallback:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. See README and consider "
|
||||
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
|
||||
"enable more frequent polling.",
|
||||
count,
|
||||
)
|
||||
broadcast_error(
|
||||
"A periodic poll task has discovered radio inconsistencies.",
|
||||
"Please check the logs for recommendations (search "
|
||||
"'MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK').",
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Error in message poll loop: %s", e)
|
||||
logger.warning("Error in message poll loop: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_message_polling():
|
||||
@@ -341,7 +496,16 @@ def start_message_polling():
|
||||
global _message_poll_task
|
||||
if _message_poll_task is None or _message_poll_task.done():
|
||||
_message_poll_task = asyncio.create_task(_message_poll_loop())
|
||||
logger.info("Started periodic message polling (interval: %ds)", MESSAGE_POLL_INTERVAL)
|
||||
if settings.enable_message_poll_fallback:
|
||||
logger.info(
|
||||
"Started periodic message polling task (aggressive fallback, interval: %ds)",
|
||||
MESSAGE_POLL_INTERVAL,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Started periodic message audit task (interval: %ds)",
|
||||
MESSAGE_POLL_AUDIT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_message_polling():
|
||||
@@ -357,21 +521,18 @@ async def stop_message_polling():
|
||||
logger.info("Stopped periodic message polling")
|
||||
|
||||
|
||||
async def send_advertisement(force: bool = False) -> bool:
|
||||
async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
||||
"""Send an advertisement to announce presence on the mesh.
|
||||
|
||||
Respects the configured advert_interval - won't send if not enough time
|
||||
has elapsed since the last advertisement, unless force=True.
|
||||
|
||||
Args:
|
||||
mc: The MeshCore instance to use for the advertisement.
|
||||
force: If True, send immediately regardless of interval.
|
||||
|
||||
Returns True if successful, False otherwise (including if throttled).
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot send advertisement: radio not connected")
|
||||
return False
|
||||
|
||||
# Check if enough time has elapsed (unless forced)
|
||||
if not force:
|
||||
settings = await AppSettingsRepository.get()
|
||||
@@ -384,6 +545,9 @@ async def send_advertisement(force: bool = False) -> bool:
|
||||
logger.debug("Advertisement skipped: periodic advertising is disabled")
|
||||
return False
|
||||
|
||||
# Enforce minimum interval floor
|
||||
interval = max(interval, MIN_ADVERT_INTERVAL)
|
||||
|
||||
# Check if enough time has passed
|
||||
elapsed = now - last_time
|
||||
if elapsed < interval:
|
||||
@@ -397,7 +561,7 @@ async def send_advertisement(force: bool = False) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = await radio_manager.meshcore.commands.send_advert(flood=True)
|
||||
result = await mc.commands.send_advert(flood=True)
|
||||
if result.type == EventType.OK:
|
||||
# Update last_advert_time in database
|
||||
now = int(time.time())
|
||||
@@ -408,7 +572,7 @@ async def send_advertisement(force: bool = False) -> bool:
|
||||
logger.warning("Failed to send advertisement: %s", result.payload)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Error sending advertisement: %s", e)
|
||||
logger.warning("Error sending advertisement: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -421,28 +585,25 @@ async def _periodic_advert_loop():
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
|
||||
# Try to send - send_advertisement() handles all checks
|
||||
# (disabled, throttled, not connected)
|
||||
if radio_manager.is_connected:
|
||||
mc = radio_manager.meshcore
|
||||
if mc is not None:
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"periodic_advertisement",
|
||||
blocking=False,
|
||||
):
|
||||
await send_advertisement()
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic advertisement: radio busy")
|
||||
|
||||
# Sleep before next check
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"periodic_advertisement",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
await send_advertisement(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic advertisement: radio busy")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Periodic advertisement task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic advertisement loop: %s", e)
|
||||
logger.error("Error in periodic advertisement loop: %s", e, exc_info=True)
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
|
||||
|
||||
@@ -471,23 +632,18 @@ async def stop_periodic_advert():
|
||||
logger.info("Stopped periodic advertisement")
|
||||
|
||||
|
||||
async def sync_radio_time() -> bool:
|
||||
async def sync_radio_time(mc: MeshCore) -> bool:
|
||||
"""Sync the radio's clock with the system time.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
mc = radio_manager.meshcore
|
||||
if not mc:
|
||||
logger.debug("Cannot sync time: radio not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
now = int(time.time())
|
||||
await mc.commands.set_time(now)
|
||||
logger.debug("Synced radio time to %d", now)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to sync radio time: %s", e)
|
||||
logger.warning("Failed to sync radio time: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -496,25 +652,25 @@ async def _periodic_sync_loop():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SYNC_INTERVAL)
|
||||
mc = radio_manager.meshcore
|
||||
if mc is None:
|
||||
cleanup_expired_acks()
|
||||
if not radio_manager.is_connected:
|
||||
continue
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"periodic_sync",
|
||||
blocking=False,
|
||||
):
|
||||
logger.debug("Running periodic radio sync")
|
||||
await sync_and_offload_all()
|
||||
await sync_radio_time()
|
||||
) as mc:
|
||||
if await should_run_full_periodic_sync(mc):
|
||||
await sync_and_offload_all(mc)
|
||||
await sync_radio_time(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic sync: radio busy")
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Periodic sync task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic sync: %s", e)
|
||||
logger.error("Error in periodic sync: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_periodic_sync():
|
||||
@@ -543,14 +699,213 @@ _last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
|
||||
|
||||
async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
|
||||
async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
|
||||
Caller must hold the radio operation lock and pass a valid MeshCore instance.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
favorite_contacts_loaded = 0
|
||||
for favorite in app_settings.favorites:
|
||||
if favorite.type != "contact":
|
||||
continue
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning(
|
||||
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
favorite_contacts_loaded += 1
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_advertised_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)",
|
||||
len(selected_contacts),
|
||||
favorite_contacts_loaded,
|
||||
refill_target,
|
||||
max_contacts,
|
||||
)
|
||||
return await _load_contacts_to_radio(mc, selected_contacts)
|
||||
|
||||
|
||||
async def ensure_contact_on_radio(
|
||||
public_key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
mc: MeshCore | None = None,
|
||||
) -> dict:
|
||||
"""Ensure one contact is loaded on the radio for ACK/routing support."""
|
||||
global _last_contact_sync
|
||||
|
||||
now = time.time()
|
||||
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
|
||||
logger.debug(
|
||||
"Single-contact sync throttled (last sync %ds ago)",
|
||||
int(now - _last_contact_sync),
|
||||
)
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning("Cannot sync favorite contact '%s': ambiguous key prefix", public_key)
|
||||
return {"loaded": 0, "error": "Ambiguous contact key prefix"}
|
||||
|
||||
if not contact:
|
||||
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
|
||||
return {"loaded": 0, "error": "Contact not found"}
|
||||
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot sync favorite contact to radio: not connected")
|
||||
return {"loaded": 0, "error": "Radio not connected"}
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"ensure_contact_on_radio",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping favorite contact sync: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
except Exception as e:
|
||||
logger.error("Error syncing favorite contact to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict:
|
||||
"""Load the provided contacts onto the radio."""
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in contacts:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
|
||||
try:
|
||||
radio_contact_payload = contact.to_radio_dict()
|
||||
result = await mc.commands.add_contact(radio_contact_payload)
|
||||
if result.type == EventType.OK:
|
||||
loaded += 1
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
|
||||
else:
|
||||
failed += 1
|
||||
reason = result.payload
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to load contact %s: %s%s",
|
||||
contact.public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Error loading contact %s with fields=%s radio_payload=%s: %s",
|
||||
contact.public_key[:12],
|
||||
_contact_sync_debug_fields(contact),
|
||||
locals().get("radio_contact_payload"),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0 or failed > 0:
|
||||
logger.info(
|
||||
"Contact sync: loaded %d, already on radio %d, failed %d",
|
||||
loaded,
|
||||
already_on_radio,
|
||||
failed,
|
||||
)
|
||||
|
||||
return {
|
||||
"loaded": loaded,
|
||||
"already_on_radio": already_on_radio,
|
||||
"failed": failed,
|
||||
}
|
||||
|
||||
|
||||
async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None = None) -> dict:
|
||||
"""
|
||||
Load contacts to the radio for DM ACK support.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
Fill order is favorites, then recently contacted non-repeaters,
|
||||
then recently advert-heard non-repeaters. Favorites are always reloaded
|
||||
up to the configured capacity; additional non-favorite fill stops at the
|
||||
80% refill target.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
|
||||
Args:
|
||||
force: Skip the throttle check.
|
||||
mc: Optional MeshCore instance. When provided, the caller already holds
|
||||
the radio operation lock and the inner logic runs directly.
|
||||
When None, this function acquires its own lock.
|
||||
|
||||
Returns counts of contacts loaded.
|
||||
"""
|
||||
global _last_contact_sync
|
||||
@@ -561,116 +916,28 @@ async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
|
||||
logger.debug("Contact sync throttled (last sync %ds ago)", int(now - _last_contact_sync))
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
# If caller provided a MeshCore instance, use it directly (caller holds the lock)
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot sync contacts to radio: not connected")
|
||||
return {"loaded": 0, "error": "Radio not connected"}
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"sync_recent_contacts_to_radio",
|
||||
blocking=False,
|
||||
):
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
|
||||
# Build prioritized contact list:
|
||||
# 1) favorite contacts, in favorite order
|
||||
# 2) most recent non-repeater contacts (excluding already-selected favorites)
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
favorite_contacts_loaded = 0
|
||||
for favorite in app_settings.favorites:
|
||||
if favorite.type != "contact":
|
||||
continue
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning(
|
||||
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
favorite_contacts_loaded += 1
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < max_contacts:
|
||||
recent_contacts = await ContactRepository.get_recent_non_repeaters(
|
||||
limit=max_contacts
|
||||
)
|
||||
for contact in recent_contacts:
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"Selected %d contacts to sync (%d favorite contacts first, limit=%d)",
|
||||
len(selected_contacts),
|
||||
favorite_contacts_loaded,
|
||||
max_contacts,
|
||||
)
|
||||
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in selected_contacts:
|
||||
# Check if already on radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
# Update DB if not marked as on_radio
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if result.type == EventType.OK:
|
||||
loaded += 1
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Failed to load contact %s: %s", contact.public_key[:12], result.payload
|
||||
)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning("Error loading contact %s: %s", contact.public_key[:12], e)
|
||||
|
||||
if loaded > 0 or failed > 0:
|
||||
logger.info(
|
||||
"Contact sync: loaded %d, already on radio %d, failed %d",
|
||||
loaded,
|
||||
already_on_radio,
|
||||
failed,
|
||||
)
|
||||
|
||||
return {
|
||||
"loaded": loaded,
|
||||
"already_on_radio": already_on_radio,
|
||||
"failed": failed,
|
||||
}
|
||||
assert mc is not None
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping contact sync to radio: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e)
|
||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
20
app/region_scope.py
Normal file
20
app/region_scope.py
Normal file
@@ -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}"
|
||||
@@ -1,981 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from hashlib import sha256
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
from app.models import (
|
||||
AppSettings,
|
||||
BotConfig,
|
||||
Channel,
|
||||
Contact,
|
||||
Favorite,
|
||||
Message,
|
||||
MessagePath,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
"""Raised when a public key prefix matches multiple contacts."""
|
||||
|
||||
def __init__(self, prefix: str, matches: list[str]):
|
||||
self.prefix = prefix.lower()
|
||||
self.matches = matches
|
||||
super().__init__(f"Ambiguous public key prefix '{self.prefix}'")
|
||||
|
||||
|
||||
class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: dict[str, Any]) -> None:
|
||||
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)
|
||||
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,
|
||||
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
|
||||
lat = COALESCE(excluded.lat, contacts.lat),
|
||||
lon = COALESCE(excluded.lon, contacts.lon),
|
||||
last_seen = excluded.last_seen,
|
||||
on_radio = excluded.on_radio,
|
||||
last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted)
|
||||
""",
|
||||
(
|
||||
contact.get("public_key", "").lower(),
|
||||
contact.get("name") or contact.get("adv_name"),
|
||||
contact.get("type", 0),
|
||||
contact.get("flags", 0),
|
||||
contact.get("last_path") or contact.get("out_path"),
|
||||
contact.get("last_path_len")
|
||||
if "last_path_len" in contact
|
||||
else contact.get("out_path_len", -1),
|
||||
contact.get("last_advert"),
|
||||
contact.get("lat") if contact.get("lat") is not None else contact.get("adv_lat"),
|
||||
contact.get("lon") if contact.get("lon") is not None else contact.get("adv_lon"),
|
||||
contact.get("last_seen", int(time.time())),
|
||||
contact.get("on_radio", False),
|
||||
contact.get("last_contacted"),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _row_to_contact(row) -> Contact:
|
||||
"""Convert a database row to a Contact model."""
|
||||
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"],
|
||||
last_advert=row["last_advert"],
|
||||
lat=row["lat"],
|
||||
lon=row["lon"],
|
||||
last_seen=row["last_seen"],
|
||||
on_radio=bool(row["on_radio"]),
|
||||
last_contacted=row["last_contacted"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key(public_key: str) -> Contact | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key = ?", (public_key.lower(),)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return ContactRepository._row_to_contact(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key_prefix(prefix: str) -> Contact | None:
|
||||
"""Get a contact by key prefix only if it resolves uniquely.
|
||||
|
||||
Returns None when no contacts match OR when multiple contacts match
|
||||
the prefix (to avoid silently selecting the wrong contact).
|
||||
"""
|
||||
normalized_prefix = prefix.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
|
||||
(f"{normalized_prefix}%",),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
return ContactRepository._row_to_contact(rows[0])
|
||||
|
||||
@staticmethod
|
||||
async def _get_prefix_matches(prefix: str, limit: int = 2) -> list[Contact]:
|
||||
"""Get contacts matching a key prefix, up to limit."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT ?",
|
||||
(f"{prefix.lower()}%", limit),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key_or_prefix(key_or_prefix: str) -> Contact | None:
|
||||
"""Get a contact by exact key match, falling back to prefix match.
|
||||
|
||||
Useful when the input might be a full 64-char public key or a shorter prefix.
|
||||
"""
|
||||
contact = await ContactRepository.get_by_key(key_or_prefix)
|
||||
if contact:
|
||||
return contact
|
||||
|
||||
matches = await ContactRepository._get_prefix_matches(key_or_prefix, limit=2)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
raise AmbiguousPublicKeyPrefixError(
|
||||
key_or_prefix,
|
||||
[m.public_key for m in matches],
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_all(limit: int = 100, offset: int = 0) -> list[Contact]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts ORDER BY COALESCE(name, public_key) LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
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).
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2
|
||||
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update_path(public_key: str, path: str, path_len: int) -> None:
|
||||
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()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
|
||||
(on_radio, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contacts WHERE public_key = ?",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_contacted(public_key: str, timestamp: int | None = None) -> None:
|
||||
"""Update the last_contacted timestamp for a contact."""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET last_contacted = ?, last_seen = ? WHERE public_key = ?",
|
||||
(ts, ts, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(public_key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a contact.
|
||||
|
||||
Returns True if a row was updated, False if contact not found.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE contacts SET last_read_at = ? WHERE public_key = ?",
|
||||
(ts, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (timestamp,))
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_pubkey_first_byte(hex_byte: str) -> list[Contact]:
|
||||
"""Get contacts whose public key starts with the given hex byte (2 chars)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE substr(public_key, 1, 2) = ?",
|
||||
(hex_byte.lower(),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
|
||||
class ChannelRepository:
|
||||
@staticmethod
|
||||
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:
|
||||
"""Upsert a channel. Key is 32-char hex string."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_hashtag = excluded.is_hashtag,
|
||||
on_radio = excluded.on_radio
|
||||
""",
|
||||
(key.upper(), name, is_hashtag, on_radio),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
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 = ?",
|
||||
(key.upper(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
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"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
await db.conn.execute(
|
||||
"DELETE FROM channels WHERE key = ?",
|
||||
(key.upper(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a channel.
|
||||
|
||||
Returns True if a row was updated, False if channel not found.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET last_read_at = ? WHERE key = ?",
|
||||
(ts, 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."""
|
||||
await db.conn.execute("UPDATE channels SET last_read_at = ?", (timestamp,))
|
||||
await db.conn.commit()
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@staticmethod
|
||||
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
|
||||
"""Parse paths JSON string to list of MessagePath objects."""
|
||||
if not paths_json:
|
||||
return None
|
||||
try:
|
||||
paths_data = json.loads(paths_json)
|
||||
return [MessagePath(**p) for p in paths_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
msg_type: str,
|
||||
text: str,
|
||||
received_at: int,
|
||||
conversation_key: str,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
) -> int | None:
|
||||
"""Create a message, returning the ID or None if duplicate.
|
||||
|
||||
Uses INSERT OR IGNORE to handle the UNIQUE constraint on
|
||||
(type, conversation_key, text, sender_timestamp). This prevents
|
||||
duplicate messages when the same message arrives via multiple RF paths.
|
||||
|
||||
The path parameter is converted to the paths JSON array format.
|
||||
"""
|
||||
# Convert single path to paths array format
|
||||
paths_json = None
|
||||
if path is not None:
|
||||
paths_json = json.dumps([{"path": path, "received_at": received_at}])
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||
received_at, paths, txt_type, signature, outgoing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
msg_type,
|
||||
conversation_key,
|
||||
text,
|
||||
sender_timestamp,
|
||||
received_at,
|
||||
paths_json,
|
||||
txt_type,
|
||||
signature,
|
||||
outgoing,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
# rowcount is 0 if INSERT was ignored due to UNIQUE constraint violation
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
return cursor.lastrowid
|
||||
|
||||
@staticmethod
|
||||
async def add_path(
|
||||
message_id: int, path: str, received_at: int | None = None
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
This is used when a repeat/echo of a message arrives via a different route.
|
||||
Returns the updated list of paths.
|
||||
"""
|
||||
ts = received_at if received_at is not None else int(time.time())
|
||||
|
||||
# Atomic append: use json_insert to avoid read-modify-write race when
|
||||
# multiple duplicate packets arrive concurrently for the same message.
|
||||
new_entry = json.dumps({"path": path, "received_at": ts})
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
COALESCE(paths, '[]'), '$[#]', json(?)
|
||||
) WHERE id = ?""",
|
||||
(new_entry, message_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
# Read back the full list for the return value
|
||||
cursor = await db.conn.execute("SELECT paths FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row["paths"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
all_paths = json.loads(row["paths"])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
return [MessagePath(**p) for p in all_paths]
|
||||
|
||||
@staticmethod
|
||||
async def claim_prefix_messages(full_key: str) -> int:
|
||||
"""Promote prefix-stored messages to the full conversation key.
|
||||
|
||||
When a full key becomes known for a contact, any messages stored with
|
||||
only a prefix as conversation_key are updated to use the full key.
|
||||
"""
|
||||
lower_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET conversation_key = ?
|
||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
before: int | None = None,
|
||||
before_id: int | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
params.append(msg_type)
|
||||
if conversation_key:
|
||||
normalized_key = conversation_key
|
||||
# Prefer exact matching for full keys.
|
||||
if len(conversation_key) == 64:
|
||||
normalized_key = conversation_key.lower()
|
||||
query += " AND conversation_key = ?"
|
||||
params.append(normalized_key)
|
||||
elif len(conversation_key) == 32:
|
||||
normalized_key = conversation_key.upper()
|
||||
query += " AND conversation_key = ?"
|
||||
params.append(normalized_key)
|
||||
else:
|
||||
# Prefix match is only for legacy/partial key callers.
|
||||
query += " AND conversation_key LIKE ?"
|
||||
params.append(f"{conversation_key}%")
|
||||
|
||||
if before is not None and before_id is not None:
|
||||
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
|
||||
cursor = await db.conn.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def increment_ack_count(message_id: int) -> int:
|
||||
"""Increment ack count and return the new value."""
|
||||
await db.conn.execute("UPDATE messages SET acked = acked + 1 WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
return row["acked"] if row else 1
|
||||
|
||||
@staticmethod
|
||||
async def get_ack_count(message_id: int) -> int:
|
||||
"""Get the current ack count for a message."""
|
||||
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
return row["acked"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_by_content(
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
) -> "Message | None":
|
||||
"""Look up a message by its unique content fields."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, txt_type, signature, outgoing, acked
|
||||
FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
""",
|
||||
(msg_type, conversation_key, text, sender_timestamp, sender_timestamp),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
paths = None
|
||||
if row["paths"]:
|
||||
try:
|
||||
paths_data = json.loads(row["paths"])
|
||||
paths = [
|
||||
MessagePath(path=p["path"], received_at=p["received_at"]) for p in paths_data
|
||||
]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=paths,
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_unread_counts(name: str | None = None) -> dict:
|
||||
"""Get unread message counts, mention flags, and last message times for all conversations.
|
||||
|
||||
Args:
|
||||
name: User's display name for @[name] mention detection. If None, mentions are skipped.
|
||||
|
||||
Returns:
|
||||
Dict with 'counts', 'mentions', and 'last_message_times' keys.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
mention_flags: dict[str, bool] = {}
|
||||
last_message_times: dict[str, int] = {}
|
||||
|
||||
mention_token = f"@[{name}]" if name else None
|
||||
|
||||
# Channel unreads
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
MAX(m.received_at) as last_message_time,
|
||||
SUM(CASE
|
||||
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or ""),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"channel-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Contact unreads
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
MAX(m.received_at) as last_message_time,
|
||||
SUM(CASE
|
||||
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or ""),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"contact-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Last message times for all conversations (including read ones)
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT type, conversation_key, MAX(received_at) as last_message_time
|
||||
FROM messages
|
||||
GROUP BY type, conversation_key
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
prefix = "channel" if row["type"] == "CHAN" else "contact"
|
||||
state_key = f"{prefix}-{row['conversation_key']}"
|
||||
last_message_times[state_key] = row["last_message_time"]
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"mentions": mention_flags,
|
||||
"last_message_times": last_message_times,
|
||||
}
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
async def create(data: bytes, timestamp: int | None = None) -> tuple[int, bool]:
|
||||
"""
|
||||
Create a raw packet with payload-based deduplication.
|
||||
|
||||
Returns (packet_id, is_new) tuple:
|
||||
- is_new=True: New packet stored, packet_id is the new row ID
|
||||
- is_new=False: Duplicate payload detected, packet_id is the existing row ID
|
||||
|
||||
Deduplication is based on the SHA-256 hash of the packet payload
|
||||
(excluding routing/path information).
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
|
||||
# Compute payload hash for deduplication
|
||||
payload = extract_payload(data)
|
||||
if payload:
|
||||
payload_hash = sha256(payload).hexdigest()
|
||||
else:
|
||||
# For malformed packets, hash the full data
|
||||
payload_hash = sha256(data).hexdigest()
|
||||
|
||||
# Check if this payload already exists
|
||||
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[: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[: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
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_count() -> int:
|
||||
"""Get count of undecrypted packets (those without a linked message)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as count FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_oldest_undecrypted() -> int | None:
|
||||
"""Get timestamp of oldest undecrypted packet, or None if none exist."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT MIN(timestamp) as oldest FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
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."""
|
||||
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]
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
"""Link a raw packet to its decrypted message."""
|
||||
await db.conn.execute(
|
||||
"UPDATE raw_packets SET message_id = ? WHERE id = ?",
|
||||
(message_id, packet_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||
cutoff = int(time.time()) - (max_age_days * 86400)
|
||||
cursor = await db.conn.execute(
|
||||
"DELETE FROM raw_packets WHERE message_id IS NULL AND timestamp < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class AppSettingsRepository:
|
||||
"""Repository for app_settings table (single-row pattern)."""
|
||||
|
||||
@staticmethod
|
||||
async def get() -> AppSettings:
|
||||
"""Get the current app settings.
|
||||
|
||||
Always returns settings - creates default row if needed (migration handles initial row).
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, experimental_channel_double_send,
|
||||
favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time, bots
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
# 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"]:
|
||||
try:
|
||||
last_message_times = json.loads(row["last_message_times"])
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse last_message_times JSON, using empty dict: %s",
|
||||
e,
|
||||
)
|
||||
last_message_times = {}
|
||||
|
||||
# Parse bots JSON
|
||||
bots: list[BotConfig] = []
|
||||
if row["bots"]:
|
||||
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 = []
|
||||
|
||||
# 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"
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
experimental_channel_double_send=bool(row["experimental_channel_double_send"]),
|
||||
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,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
experimental_channel_double_send: bool | 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,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
params: list[Any] = []
|
||||
|
||||
if max_radio_contacts is not None:
|
||||
updates.append("max_radio_contacts = ?")
|
||||
params.append(max_radio_contacts)
|
||||
|
||||
if experimental_channel_double_send is not None:
|
||||
updates.append("experimental_channel_double_send = ?")
|
||||
params.append(1 if experimental_channel_double_send else 0)
|
||||
|
||||
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)
|
||||
|
||||
if last_advert_time is not None:
|
||||
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 updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
await db.conn.commit()
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@staticmethod
|
||||
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Add a favorite, avoiding duplicates."""
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Remove a favorite."""
|
||||
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
|
||||
24
app/repository/__init__.py
Normal file
24
app/repository/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from app.repository.channels import ChannelRepository
|
||||
from app.repository.contacts import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
)
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.repository.messages import MessageRepository
|
||||
from app.repository.raw_packets import RawPacketRepository
|
||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||
|
||||
__all__ = [
|
||||
"AmbiguousPublicKeyPrefixError",
|
||||
"AppSettingsRepository",
|
||||
"ChannelRepository",
|
||||
"ContactAdvertPathRepository",
|
||||
"ContactNameHistoryRepository",
|
||||
"ContactRepository",
|
||||
"FanoutConfigRepository",
|
||||
"MessageRepository",
|
||||
"RawPacketRepository",
|
||||
"StatisticsRepository",
|
||||
]
|
||||
106
app/repository/channels.py
Normal file
106
app/repository/channels.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import time
|
||||
|
||||
from app.database import db
|
||||
from app.models import Channel
|
||||
|
||||
|
||||
class ChannelRepository:
|
||||
@staticmethod
|
||||
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:
|
||||
"""Upsert a channel. Key is 32-char hex string."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
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,
|
||||
on_radio = excluded.on_radio
|
||||
""",
|
||||
(key.upper(), name, is_hashtag, on_radio),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
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, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
(key.upper(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
await db.conn.execute(
|
||||
"DELETE FROM channels WHERE key = ?",
|
||||
(key.upper(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a channel.
|
||||
|
||||
Returns True if a row was updated, False if channel not found.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET last_read_at = ? WHERE key = ?",
|
||||
(ts, key.upper()),
|
||||
)
|
||||
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 mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE channels SET last_read_at = ?", (timestamp,))
|
||||
await db.conn.commit()
|
||||
568
app/repository/contacts.py
Normal file
568
app/repository/contacts.py
Normal file
@@ -0,0 +1,568 @@
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactNameHistory,
|
||||
ContactUpsert,
|
||||
)
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
"""Raised when a public key prefix matches multiple contacts."""
|
||||
|
||||
def __init__(self, prefix: str, matches: list[str]):
|
||||
self.prefix = prefix.lower()
|
||||
self.matches = matches
|
||||
super().__init__(f"Ambiguous public key prefix '{self.prefix}'")
|
||||
|
||||
|
||||
class ContactRepository:
|
||||
@staticmethod
|
||||
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)
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
contact_row.last_path,
|
||||
contact_row.last_path_len,
|
||||
contact_row.out_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,
|
||||
out_path_hash_mode,
|
||||
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,
|
||||
out_path_hash_mode = excluded.out_path_hash_mode,
|
||||
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),
|
||||
last_seen = excluded.last_seen,
|
||||
on_radio = COALESCE(excluded.on_radio, contacts.on_radio),
|
||||
last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted),
|
||||
first_seen = COALESCE(contacts.first_seen, excluded.first_seen)
|
||||
""",
|
||||
(
|
||||
contact_row.public_key.lower(),
|
||||
contact_row.name,
|
||||
contact_row.type,
|
||||
contact_row.flags,
|
||||
last_path,
|
||||
last_path_len,
|
||||
out_path_hash_mode,
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
def _row_to_contact(row) -> Contact:
|
||||
"""Convert a database row to a Contact model."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
row["last_path"],
|
||||
row["last_path_len"],
|
||||
row["out_path_hash_mode"],
|
||||
)
|
||||
available_columns = set(row.keys())
|
||||
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=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
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"]),
|
||||
last_contacted=row["last_contacted"],
|
||||
last_read_at=row["last_read_at"],
|
||||
first_seen=row["first_seen"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key(public_key: str) -> Contact | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key = ?", (public_key.lower(),)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return ContactRepository._row_to_contact(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key_prefix(prefix: str) -> Contact | None:
|
||||
"""Get a contact by key prefix only if it resolves uniquely.
|
||||
|
||||
Returns None when no contacts match OR when multiple contacts match
|
||||
the prefix (to avoid silently selecting the wrong contact).
|
||||
"""
|
||||
normalized_prefix = prefix.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
|
||||
(f"{normalized_prefix}%",),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
return ContactRepository._row_to_contact(rows[0])
|
||||
|
||||
@staticmethod
|
||||
async def _get_prefix_matches(prefix: str, limit: int = 2) -> list[Contact]:
|
||||
"""Get contacts matching a key prefix, up to limit."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT ?",
|
||||
(f"{prefix.lower()}%", limit),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key_or_prefix(key_or_prefix: str) -> Contact | None:
|
||||
"""Get a contact by exact key match, falling back to prefix match.
|
||||
|
||||
Useful when the input might be a full 64-char public key or a shorter prefix.
|
||||
"""
|
||||
contact = await ContactRepository.get_by_key(key_or_prefix)
|
||||
if contact:
|
||||
return contact
|
||||
|
||||
matches = await ContactRepository._get_prefix_matches(key_or_prefix, limit=2)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
raise AmbiguousPublicKeyPrefixError(
|
||||
key_or_prefix,
|
||||
[m.public_key for m in matches],
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_by_name(name: str) -> list[Contact]:
|
||||
"""Get all contacts with the given exact name."""
|
||||
cursor = await db.conn.execute("SELECT * FROM contacts WHERE name = ?", (name,))
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def resolve_prefixes(prefixes: list[str]) -> dict[str, Contact]:
|
||||
"""Resolve multiple key prefixes to contacts in a single query.
|
||||
|
||||
Returns a dict mapping each prefix to its Contact, only for prefixes
|
||||
that resolve uniquely (exactly one match). Ambiguous or unmatched
|
||||
prefixes are omitted.
|
||||
"""
|
||||
if not prefixes:
|
||||
return {}
|
||||
normalized = [p.lower() for p in prefixes]
|
||||
conditions = " OR ".join(["public_key LIKE ?"] * len(normalized))
|
||||
params = [f"{p}%" for p in normalized]
|
||||
cursor = await db.conn.execute(f"SELECT * FROM contacts WHERE {conditions}", params)
|
||||
rows = await cursor.fetchall()
|
||||
# Group by which prefix each row matches
|
||||
prefix_to_rows: dict[str, list] = {p: [] for p in normalized}
|
||||
for row in rows:
|
||||
pk = row["public_key"]
|
||||
for p in normalized:
|
||||
if pk.startswith(p):
|
||||
prefix_to_rows[p].append(row)
|
||||
# Only include uniquely-resolved prefixes
|
||||
result: dict[str, Contact] = {}
|
||||
for p in normalized:
|
||||
if len(prefix_to_rows[p]) == 1:
|
||||
result[p] = ContactRepository._row_to_contact(prefix_to_rows[p][0])
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_all(limit: int = 100, offset: int = 0) -> list[Contact]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts ORDER BY COALESCE(name, public_key) LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
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 AND last_contacted IS NOT NULL
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
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_path(
|
||||
public_key: str,
|
||||
path: str,
|
||||
path_len: int,
|
||||
out_path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
|
||||
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
|
||||
last_seen = ? WHERE public_key = ?""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_path_len,
|
||||
normalized_hash_mode,
|
||||
int(time.time()),
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_routing_override(
|
||||
public_key: str,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
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 set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
|
||||
(on_radio, 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 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,)
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_contacted(public_key: str, timestamp: int | None = None) -> None:
|
||||
"""Update the last_contacted timestamp for a contact."""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET last_contacted = ?, last_seen = ? WHERE public_key = ?",
|
||||
(ts, ts, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(public_key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a contact.
|
||||
|
||||
Returns True if a row was updated, False if contact not found.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE contacts SET last_read_at = ? WHERE public_key = ?",
|
||||
(ts, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (timestamp,))
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_pubkey_first_byte(hex_byte: str) -> list[Contact]:
|
||||
"""Get contacts whose public key starts with the given hex byte (2 chars)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE substr(public_key, 1, 2) = ?",
|
||||
(hex_byte.lower(),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
|
||||
class ContactAdvertPathRepository:
|
||||
"""Repository for recent unique advertisement paths per contact."""
|
||||
|
||||
@staticmethod
|
||||
def _row_to_path(row) -> ContactAdvertPath:
|
||||
path = row["path_hex"] or ""
|
||||
path_len = row["path_len"]
|
||||
next_hop = first_hop_hex(path, path_len)
|
||||
return ContactAdvertPath(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
next_hop=next_hop,
|
||||
first_seen=row["first_seen"],
|
||||
last_seen=row["last_seen"],
|
||||
heard_count=row["heard_count"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def record_observation(
|
||||
public_key: str,
|
||||
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.
|
||||
"""
|
||||
if max_paths < 1:
|
||||
max_paths = 1
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
normalized_path = path_hex.lower()
|
||||
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, path_len) DO UPDATE SET
|
||||
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||
heard_count = contact_advert_paths.heard_count + 1
|
||||
""",
|
||||
(normalized_key, normalized_path, path_len, timestamp, timestamp),
|
||||
)
|
||||
|
||||
# Keep only the N most recent unique paths per contact.
|
||||
await db.conn.execute(
|
||||
"""
|
||||
DELETE FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
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
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(normalized_key, normalized_key, max_paths),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_for_contact(public_key: str, limit: int = 10) -> list[ContactAdvertPath]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(public_key.lower(), limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactAdvertPathRepository._row_to_path(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_for_all_contacts(
|
||||
limit_per_contact: int = 10,
|
||||
) -> list[ContactAdvertPathSummary]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
grouped: dict[str, list[ContactAdvertPath]] = {}
|
||||
for row in rows:
|
||||
key = row["public_key"]
|
||||
paths = grouped.get(key)
|
||||
if paths is None:
|
||||
paths = []
|
||||
grouped[key] = paths
|
||||
if len(paths) >= limit_per_contact:
|
||||
continue
|
||||
paths.append(ContactAdvertPathRepository._row_to_path(row))
|
||||
|
||||
return [
|
||||
ContactAdvertPathSummary(public_key=key, paths=paths) for key, paths in grouped.items()
|
||||
]
|
||||
|
||||
|
||||
class ContactNameHistoryRepository:
|
||||
"""Repository for contact name change history."""
|
||||
|
||||
@staticmethod
|
||||
async def record_name(public_key: str, name: str, timestamp: int) -> None:
|
||||
"""Record a name observation. Upserts: updates last_seen if name already known."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(public_key, name) DO UPDATE SET
|
||||
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
|
||||
""",
|
||||
(public_key.lower(), name, timestamp, timestamp),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_history(public_key: str) -> list[ContactNameHistory]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT name, first_seen, last_seen
|
||||
FROM contact_name_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC
|
||||
""",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
ContactNameHistory(
|
||||
name=row["name"], first_seen=row["first_seen"], last_seen=row["last_seen"]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
137
app/repository/fanout.py
Normal file
137
app/repository/fanout.py
Normal file
@@ -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]
|
||||
634
app/repository/messages.py
Normal file
634
app/repository/messages.py
Normal file
@@ -0,0 +1,634 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import Message, MessagePath
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@staticmethod
|
||||
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
|
||||
"""Parse paths JSON string to list of MessagePath objects."""
|
||||
if not paths_json:
|
||||
return None
|
||||
try:
|
||||
paths_data = json.loads(paths_json)
|
||||
return [MessagePath(**p) for p in paths_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
msg_type: str,
|
||||
text: str,
|
||||
received_at: int,
|
||||
conversation_key: str,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
sender_name: str | None = None,
|
||||
sender_key: str | None = None,
|
||||
) -> int | None:
|
||||
"""Create a message, returning the ID or None if duplicate.
|
||||
|
||||
Uses INSERT OR IGNORE to handle the UNIQUE constraint on
|
||||
(type, conversation_key, text, sender_timestamp). This prevents
|
||||
duplicate messages when the same message arrives via multiple RF paths.
|
||||
|
||||
The path parameter is converted to the paths JSON array format.
|
||||
"""
|
||||
# Convert single path to paths array format
|
||||
paths_json = None
|
||||
if path is not None:
|
||||
entry: dict = {"path": path, "received_at": received_at}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
paths_json = json.dumps([entry])
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||
received_at, paths, txt_type, signature, outgoing,
|
||||
sender_name, sender_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
msg_type,
|
||||
conversation_key,
|
||||
text,
|
||||
sender_timestamp,
|
||||
received_at,
|
||||
paths_json,
|
||||
txt_type,
|
||||
signature,
|
||||
outgoing,
|
||||
sender_name,
|
||||
sender_key,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
# rowcount is 0 if INSERT was ignored due to UNIQUE constraint violation
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
return cursor.lastrowid
|
||||
|
||||
@staticmethod
|
||||
async def add_path(
|
||||
message_id: int,
|
||||
path: str,
|
||||
received_at: int | None = None,
|
||||
path_len: int | None = None,
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
This is used when a repeat/echo of a message arrives via a different route.
|
||||
Returns the updated list of paths.
|
||||
"""
|
||||
ts = received_at if received_at is not None else int(time.time())
|
||||
|
||||
# Atomic append: use json_insert to avoid read-modify-write race when
|
||||
# multiple duplicate packets arrive concurrently for the same message.
|
||||
entry: dict = {"path": path, "received_at": ts}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
new_entry = json.dumps(entry)
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
COALESCE(paths, '[]'), '$[#]', json(?)
|
||||
) WHERE id = ?""",
|
||||
(new_entry, message_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
# Read back the full list for the return value
|
||||
cursor = await db.conn.execute("SELECT paths FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row["paths"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
all_paths = json.loads(row["paths"])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
return [MessagePath(**p) for p in all_paths]
|
||||
|
||||
@staticmethod
|
||||
async def claim_prefix_messages(full_key: str) -> int:
|
||||
"""Promote prefix-stored messages to the full conversation key.
|
||||
|
||||
When a full key becomes known for a contact, any messages stored with
|
||||
only a prefix as conversation_key are updated to use the full key.
|
||||
"""
|
||||
lower_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET conversation_key = ?
|
||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def backfill_channel_sender_key(public_key: str, name: str) -> int:
|
||||
"""Backfill sender_key on channel messages that match a contact's name.
|
||||
|
||||
When a contact becomes known (via advert, sync, or manual creation),
|
||||
any channel messages with a matching sender_name but no sender_key
|
||||
are updated to associate them with this contact's public key.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET sender_key = ?
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
|
||||
(public_key.lower(), name),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
def _normalize_conversation_key(conversation_key: str) -> tuple[str, str]:
|
||||
"""Normalize a conversation key and return (sql_clause, normalized_key).
|
||||
|
||||
Returns the WHERE clause fragment and the normalized key value.
|
||||
"""
|
||||
if len(conversation_key) == 64:
|
||||
return "AND conversation_key = ?", conversation_key.lower()
|
||||
elif len(conversation_key) == 32:
|
||||
return "AND conversation_key = ?", conversation_key.upper()
|
||||
else:
|
||||
return "AND conversation_key LIKE ?", f"{conversation_key}%"
|
||||
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
sender_key=row["sender_key"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
sender_name=row["sender_name"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
before: int | None = None,
|
||||
before_id: int | None = None,
|
||||
after: int | None = None,
|
||||
after_id: int | None = None,
|
||||
q: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND sender_name IS NOT NULL"
|
||||
f" AND sender_name IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_names)
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
params.append(msg_type)
|
||||
if conversation_key:
|
||||
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
|
||||
query += f" {clause}"
|
||||
params.append(norm_key)
|
||||
|
||||
if q:
|
||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"%{escaped_q}%")
|
||||
|
||||
# Forward cursor (after/after_id) — mutually exclusive with before/before_id
|
||||
if after is not None and after_id is not None:
|
||||
query += " AND (received_at > ? OR (received_at = ? AND id > ?))"
|
||||
params.extend([after, after, after_id])
|
||||
query += " ORDER BY received_at ASC, id ASC LIMIT ?"
|
||||
params.append(limit)
|
||||
else:
|
||||
if before is not None and before_id is not None:
|
||||
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
|
||||
cursor = await db.conn.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [MessageRepository._row_to_message(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_around(
|
||||
message_id: int,
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
context_size: int = 100,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> tuple[list[Message], bool, bool]:
|
||||
"""Get messages around a target message.
|
||||
|
||||
Returns (messages, has_older, has_newer).
|
||||
"""
|
||||
# Build common WHERE clause for optional conversation/type filtering.
|
||||
# If the target message doesn't match filters, return an empty result.
|
||||
where_parts: list[str] = []
|
||||
base_params: list[Any] = []
|
||||
if msg_type:
|
||||
where_parts.append("type = ?")
|
||||
base_params.append(msg_type)
|
||||
if conversation_key:
|
||||
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
|
||||
where_parts.append(clause.removeprefix("AND "))
|
||||
base_params.append(norm_key)
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
where_parts.append(
|
||||
f"NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
base_params.extend(blocked_keys)
|
||||
base_params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
where_parts.append(
|
||||
f"NOT (outgoing=0 AND sender_name IS NOT NULL AND sender_name IN ({placeholders}))"
|
||||
)
|
||||
base_params.extend(blocked_names)
|
||||
|
||||
where_sql = " AND ".join(["1=1", *where_parts])
|
||||
|
||||
# 1. Get the target message (must satisfy filters if provided)
|
||||
target_cursor = await db.conn.execute(
|
||||
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
|
||||
(message_id, *base_params),
|
||||
)
|
||||
target_row = await target_cursor.fetchone()
|
||||
if not target_row:
|
||||
return [], False, False
|
||||
|
||||
target = MessageRepository._row_to_message(target_row)
|
||||
|
||||
# 2. Get context_size+1 messages before target (DESC)
|
||||
before_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
AND (received_at < ? OR (received_at = ? AND id < ?))
|
||||
ORDER BY received_at DESC, id DESC LIMIT ?
|
||||
"""
|
||||
before_params = [
|
||||
*base_params,
|
||||
target.received_at,
|
||||
target.received_at,
|
||||
target.id,
|
||||
context_size + 1,
|
||||
]
|
||||
before_cursor = await db.conn.execute(before_query, before_params)
|
||||
before_rows = list(await before_cursor.fetchall())
|
||||
|
||||
has_older = len(before_rows) > context_size
|
||||
before_messages = [MessageRepository._row_to_message(r) for r in before_rows[:context_size]]
|
||||
|
||||
# 3. Get context_size+1 messages after target (ASC)
|
||||
after_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
AND (received_at > ? OR (received_at = ? AND id > ?))
|
||||
ORDER BY received_at ASC, id ASC LIMIT ?
|
||||
"""
|
||||
after_params = [
|
||||
*base_params,
|
||||
target.received_at,
|
||||
target.received_at,
|
||||
target.id,
|
||||
context_size + 1,
|
||||
]
|
||||
after_cursor = await db.conn.execute(after_query, after_params)
|
||||
after_rows = list(await after_cursor.fetchall())
|
||||
|
||||
has_newer = len(after_rows) > context_size
|
||||
after_messages = [MessageRepository._row_to_message(r) for r in after_rows[:context_size]]
|
||||
|
||||
# Combine: before (reversed to ASC) + target + after
|
||||
all_messages = list(reversed(before_messages)) + [target] + after_messages
|
||||
return all_messages, has_older, has_newer
|
||||
|
||||
@staticmethod
|
||||
async def increment_ack_count(message_id: int) -> int:
|
||||
"""Increment ack count and return the new value."""
|
||||
await db.conn.execute("UPDATE messages SET acked = acked + 1 WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
return row["acked"] if row else 1
|
||||
|
||||
@staticmethod
|
||||
async def get_ack_and_paths(message_id: int) -> tuple[int, list[MessagePath] | None]:
|
||||
"""Get the current ack count and paths for a message."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT acked, paths FROM messages WHERE id = ?", (message_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return 0, None
|
||||
return row["acked"], MessageRepository._parse_paths(row["paths"])
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(message_id: int) -> "Message | None":
|
||||
"""Look up a message by its ID."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM messages WHERE id = ?",
|
||||
(message_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return MessageRepository._row_to_message(row)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_content(
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
) -> "Message | None":
|
||||
"""Look up a message by its unique content fields."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
""",
|
||||
(msg_type, conversation_key, text, sender_timestamp, sender_timestamp),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return MessageRepository._row_to_message(row)
|
||||
|
||||
@staticmethod
|
||||
async def get_unread_counts(
|
||||
name: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Get unread message counts, mention flags, and last message times for all conversations.
|
||||
|
||||
Args:
|
||||
name: User's display name for @[name] mention detection. If None, mentions are skipped.
|
||||
blocked_keys: Public keys whose messages should be excluded from counts.
|
||||
blocked_names: Display names whose messages should be excluded from counts.
|
||||
|
||||
Returns:
|
||||
Dict with 'counts', 'mentions', and 'last_message_times' keys.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
mention_flags: dict[str, bool] = {}
|
||||
last_message_times: dict[str, int] = {}
|
||||
|
||||
mention_token = f"@[{name}]" if name else None
|
||||
|
||||
# Build optional block-list WHERE fragments for channel messages
|
||||
chan_block_sql = ""
|
||||
chan_block_params: list[Any] = []
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
chan_block_sql += (
|
||||
f" AND NOT (m.sender_key IS NOT NULL AND LOWER(m.sender_key) IN ({placeholders}))"
|
||||
)
|
||||
chan_block_params.extend(blocked_keys)
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
chan_block_sql += (
|
||||
f" AND NOT (m.sender_name IS NOT NULL AND m.sender_name IN ({placeholders}))"
|
||||
)
|
||||
chan_block_params.extend(blocked_names)
|
||||
|
||||
# Channel unreads
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
SUM(CASE
|
||||
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
{chan_block_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or "", *chan_block_params),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"channel-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Build block-list exclusion for contact (DM) unreads
|
||||
contact_block_sql = ""
|
||||
contact_block_params: list[Any] = []
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
contact_block_sql += f" AND LOWER(m.conversation_key) NOT IN ({placeholders})"
|
||||
contact_block_params.extend(blocked_keys)
|
||||
|
||||
# Contact unreads
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
SUM(CASE
|
||||
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
{contact_block_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or "", *contact_block_params),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"contact-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Last message times for all conversations (including read ones)
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT type, conversation_key, MAX(received_at) as last_message_time
|
||||
FROM messages
|
||||
GROUP BY type, conversation_key
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
prefix = "channel" if row["type"] == "CHAN" else "contact"
|
||||
state_key = f"{prefix}-{row['conversation_key']}"
|
||||
last_message_times[state_key] = row["last_message_time"]
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"mentions": mention_flags,
|
||||
"last_message_times": last_message_times,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def count_dm_messages(contact_key: str) -> int:
|
||||
"""Count total DM messages for a contact."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'PRIV' AND conversation_key = ?",
|
||||
(contact_key.lower(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def count_channel_messages_by_sender(sender_key: str) -> int:
|
||||
"""Count channel messages sent by a specific contact."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_key = ?",
|
||||
(sender_key.lower(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_channel_stats(conversation_key: str) -> dict:
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
|
||||
|
||||
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
now = int(_time.time())
|
||||
t_1h = now - 3600
|
||||
t_24h = now - 86400
|
||||
t_48h = now - 172800
|
||||
t_7d = now - 604800
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS all_time,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_1h,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_24h,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_48h,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_7d,
|
||||
MIN(received_at) AS first_message_at,
|
||||
COUNT(DISTINCT sender_key) AS unique_sender_count
|
||||
FROM messages WHERE type = 'CHAN' AND conversation_key = ?
|
||||
""",
|
||||
(t_1h, t_24h, t_48h, t_7d, conversation_key),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None # Aggregate query always returns a row
|
||||
|
||||
message_counts = {
|
||||
"last_1h": row["last_1h"] or 0,
|
||||
"last_24h": row["last_24h"] or 0,
|
||||
"last_48h": row["last_48h"] or 0,
|
||||
"last_7d": row["last_7d"] or 0,
|
||||
"all_time": row["all_time"] or 0,
|
||||
}
|
||||
|
||||
cursor2 = await db.conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(sender_name, sender_key, 'Unknown') AS display_name,
|
||||
sender_key, COUNT(*) AS cnt
|
||||
FROM messages
|
||||
WHERE type = 'CHAN' AND conversation_key = ?
|
||||
AND received_at >= ? AND sender_key IS NOT NULL
|
||||
GROUP BY sender_key ORDER BY cnt DESC LIMIT 5
|
||||
""",
|
||||
(conversation_key, t_24h),
|
||||
)
|
||||
top_rows = await cursor2.fetchall()
|
||||
top_senders = [
|
||||
{
|
||||
"sender_name": r["display_name"],
|
||||
"sender_key": r["sender_key"],
|
||||
"message_count": r["cnt"],
|
||||
}
|
||||
for r in top_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"message_counts": message_counts,
|
||||
"first_message_at": row["first_message_at"],
|
||||
"unique_sender_count": row["unique_sender_count"] or 0,
|
||||
"top_senders_24h": top_senders,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]:
|
||||
"""Get channels where a contact has sent the most messages.
|
||||
|
||||
Returns list of (channel_key, channel_name, message_count) tuples.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name,
|
||||
COUNT(*) AS cnt
|
||||
FROM messages m
|
||||
LEFT JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.sender_key = ?
|
||||
GROUP BY m.conversation_key
|
||||
ORDER BY cnt DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(sender_key.lower(), limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
|
||||
150
app/repository/raw_packets.py
Normal file
150
app/repository/raw_packets.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from hashlib import sha256
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
async def create(data: bytes, timestamp: int | None = None) -> tuple[int, bool]:
|
||||
"""
|
||||
Create a raw packet with payload-based deduplication.
|
||||
|
||||
Returns (packet_id, is_new) tuple:
|
||||
- is_new=True: New packet stored, packet_id is the new row ID
|
||||
- is_new=False: Duplicate payload detected, packet_id is the existing row ID
|
||||
|
||||
Deduplication is based on the SHA-256 hash of the packet payload
|
||||
(excluding routing/path information).
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
|
||||
# Compute payload hash for deduplication
|
||||
payload = extract_payload(data)
|
||||
if payload:
|
||||
payload_hash = sha256(payload).digest()
|
||||
else:
|
||||
# For malformed packets, hash the full data
|
||||
payload_hash = sha256(data).digest()
|
||||
|
||||
# Check if this payload already exists
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_count() -> int:
|
||||
"""Get count of undecrypted packets (those without a linked message)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as count FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_oldest_undecrypted() -> int | None:
|
||||
"""Get timestamp of oldest undecrypted packet, or None if none exist."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT MIN(timestamp) as oldest FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
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."""
|
||||
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]
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
"""Link a raw packet to its decrypted message."""
|
||||
await db.conn.execute(
|
||||
"UPDATE raw_packets SET message_id = ? WHERE id = ?",
|
||||
(message_id, packet_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||
cutoff = int(time.time()) - (max_age_days * 86400)
|
||||
cursor = await db.conn.execute(
|
||||
"DELETE FROM raw_packets WHERE message_id IS NULL AND timestamp < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def purge_linked_to_messages() -> int:
|
||||
"""Delete raw packets that are already linked to a stored message."""
|
||||
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
|
||||
415
app/repository/settings.py
Normal file
415
app/repository/settings.py
Normal file
@@ -0,0 +1,415 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, Favorite
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECONDS_1H = 3600
|
||||
SECONDS_24H = 86400
|
||||
SECONDS_7D = 604800
|
||||
|
||||
|
||||
class AppSettingsRepository:
|
||||
"""Repository for app_settings table (single-row pattern)."""
|
||||
|
||||
@staticmethod
|
||||
async def get() -> AppSettings:
|
||||
"""Get the current app settings.
|
||||
|
||||
Always returns settings - creates default row if needed (migration handles initial row).
|
||||
"""
|
||||
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, flood_scope,
|
||||
blocked_keys, blocked_names
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
# 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"]:
|
||||
try:
|
||||
last_message_times = json.loads(row["last_message_times"])
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse last_message_times JSON, using empty dict: %s",
|
||||
e,
|
||||
)
|
||||
last_message_times = {}
|
||||
|
||||
# Parse blocked_keys JSON
|
||||
blocked_keys: list[str] = []
|
||||
if row["blocked_keys"]:
|
||||
try:
|
||||
blocked_keys = json.loads(row["blocked_keys"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_keys = []
|
||||
|
||||
# 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 = []
|
||||
|
||||
# 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"
|
||||
|
||||
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,
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
|
||||
@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,
|
||||
flood_scope: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
params: list[Any] = []
|
||||
|
||||
if max_radio_contacts is not None:
|
||||
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)
|
||||
|
||||
if last_advert_time is not None:
|
||||
updates.append("last_advert_time = ?")
|
||||
params.append(last_advert_time)
|
||||
|
||||
if flood_scope is not None:
|
||||
updates.append("flood_scope = ?")
|
||||
params.append(flood_scope)
|
||||
|
||||
if blocked_keys is not None:
|
||||
updates.append("blocked_keys = ?")
|
||||
params.append(json.dumps(blocked_keys))
|
||||
|
||||
if blocked_names is not None:
|
||||
updates.append("blocked_names = ?")
|
||||
params.append(json.dumps(blocked_names))
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
await db.conn.commit()
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@staticmethod
|
||||
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Add a favorite, avoiding duplicates."""
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Remove a favorite."""
|
||||
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 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()
|
||||
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 toggle_blocked_name(name: str) -> AppSettings:
|
||||
"""Toggle a display name in the blocked list."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
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)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||
now = int(time.time())
|
||||
op = "!=" if exclude else "="
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_week
|
||||
FROM contacts
|
||||
WHERE type {op} ? AND last_seen IS NOT NULL
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D, contact_type),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None # Aggregate query always returns a row
|
||||
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 _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,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
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_packets = single_byte + double_byte + triple_byte
|
||||
if total_packets == 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_packets,
|
||||
"single_byte": single_byte,
|
||||
"double_byte": double_byte,
|
||||
"triple_byte": triple_byte,
|
||||
"single_byte_pct": (single_byte / total_packets) * 100,
|
||||
"double_byte_pct": (double_byte / total_packets) * 100,
|
||||
"triple_byte_pct": (triple_byte / total_packets) * 100,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
"""Aggregate all statistics from existing tables."""
|
||||
now = int(time.time())
|
||||
|
||||
# Top 5 busiest channels in last 24h
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name,
|
||||
COUNT(*) AS message_count
|
||||
FROM messages m
|
||||
LEFT JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.received_at >= ?
|
||||
GROUP BY m.conversation_key
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
busiest_channels_24h = [
|
||||
{
|
||||
"channel_key": row["conversation_key"],
|
||||
"channel_name": row["channel_name"],
|
||||
"message_count": row["message_count"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Entity counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type != 2")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
contact_count: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type = 2")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
repeater_count: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM channels")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
channel_count: int = row["cnt"]
|
||||
|
||||
# Packet split
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN message_id IS NOT NULL THEN 1 ELSE 0 END) AS decrypted
|
||||
FROM raw_packets
|
||||
"""
|
||||
)
|
||||
pkt_row = await cursor.fetchone()
|
||||
assert pkt_row is not None
|
||||
total_packets = pkt_row["total"] or 0
|
||||
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"]
|
||||
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
|
||||
return {
|
||||
"busiest_channels_24h": busiest_channels_24h,
|
||||
"contact_count": contact_count,
|
||||
"repeater_count": repeater_count,
|
||||
"channel_count": channel_count,
|
||||
"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,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
@@ -6,10 +6,12 @@ from meshcore import EventType
|
||||
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 ensure_default_channels
|
||||
from app.repository import ChannelRepository
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
@@ -23,14 +25,36 @@ class CreateChannelRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
flood_scope_override: str = Field(
|
||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
# Ensure Public channel always exists (self-healing)
|
||||
await ensure_default_channels()
|
||||
return await ChannelRepository.get_all()
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
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"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=Channel)
|
||||
async def get_channel(key: str) -> Channel:
|
||||
"""Get a specific channel by key (32-char hex string)."""
|
||||
@@ -79,42 +103,29 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
flood_scope_override=None,
|
||||
)
|
||||
|
||||
|
||||
@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."""
|
||||
mc = require_connected()
|
||||
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"):
|
||||
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:
|
||||
payload = result.payload
|
||||
name = payload.get("channel_name", "")
|
||||
secret = payload.get("channel_secret", b"")
|
||||
|
||||
# Skip empty channels
|
||||
if not name or name == "\x00" * len(name):
|
||||
continue
|
||||
|
||||
is_hashtag = name.startswith("#")
|
||||
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
|
||||
key_hex = key_bytes.hex().upper()
|
||||
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=True,
|
||||
)
|
||||
count += 1
|
||||
logger.debug("Synced channel %s: %s", key_hex, name)
|
||||
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}
|
||||
@@ -134,6 +145,28 @@ 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.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
@@ -143,4 +176,7 @@ async def delete_channel(key: str) -> dict:
|
||||
"""
|
||||
logger.info("Deleting channel %s from database", key)
|
||||
await ChannelRepository.delete(key)
|
||||
|
||||
broadcast_event("channel_deleted", {"key": key})
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
|
||||
@@ -7,35 +6,33 @@ from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
AclEntry,
|
||||
CommandRequest,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
NeighborInfo,
|
||||
TelemetryRequest,
|
||||
TelemetryResponse,
|
||||
NearestRepeater,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, ContactRepository, MessageRepository
|
||||
from app.path_utils import parse_explicit_hop_route
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ACL permission level names
|
||||
ACL_PERMISSION_NAMES = {
|
||||
0: "Guest",
|
||||
1: "Read-only",
|
||||
2: "Read-write",
|
||||
3: "Admin",
|
||||
}
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
# Delay between repeater radio operations to allow key exchange and path establishment
|
||||
REPEATER_OP_DELAY_SECONDS = 2.0
|
||||
|
||||
|
||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||
@@ -57,31 +54,41 @@ async def _resolve_contact_or_404(
|
||||
return contact
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> None:
|
||||
"""Prepare connection to a repeater by adding to radio and logging in.
|
||||
async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
"""Add a contact to the radio for routing, raising 500 on failure."""
|
||||
add_result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if add_result is not None and add_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}"
|
||||
)
|
||||
|
||||
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
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
|
||||
"""Push the current effective route to the radio when the contact is already loaded."""
|
||||
if not radio_manager.is_connected or not contact.on_radio:
|
||||
return
|
||||
|
||||
# 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)
|
||||
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,
|
||||
)
|
||||
|
||||
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 _broadcast_contact_update(contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
@@ -93,6 +100,20 @@ async def list_contacts(
|
||||
return await ContactRepository.get_all(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/repeaters/advert-paths", response_model=list[ContactAdvertPathSummary])
|
||||
async def list_repeater_advert_paths(
|
||||
limit_per_repeater: int = Query(default=10, ge=1, le=50),
|
||||
) -> list[ContactAdvertPathSummary]:
|
||||
"""List recent unique advert paths for all repeaters.
|
||||
|
||||
Note: This endpoint now returns paths for all contacts (table was renamed).
|
||||
The route is kept for backward compatibility.
|
||||
"""
|
||||
return await ContactAdvertPathRepository.get_recent_for_all_contacts(
|
||||
limit_per_contact=limit_per_repeater
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=Contact)
|
||||
async def create_contact(
|
||||
request: CreateContactRequest, background_tasks: BackgroundTasks
|
||||
@@ -113,23 +134,10 @@ async def create_contact(
|
||||
if existing:
|
||||
# Update name if provided
|
||||
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,
|
||||
}
|
||||
)
|
||||
existing.name = request.name
|
||||
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
|
||||
|
||||
# Trigger historical decryption if requested (even for existing contacts)
|
||||
if request.try_historical:
|
||||
@@ -141,33 +149,104 @@ async def create_contact(
|
||||
|
||||
# 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,
|
||||
out_path_hash_mode=-1,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
logger.info("Created contact %s", lower_key[:12])
|
||||
|
||||
# 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 reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
contact_name=request.name,
|
||||
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)
|
||||
return Contact(**contact_upsert.model_dump())
|
||||
|
||||
|
||||
@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:
|
||||
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
|
||||
)
|
||||
|
||||
# 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)
|
||||
@@ -176,14 +255,25 @@ async def get_contact(public_key: str) -> Contact:
|
||||
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."""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
logger.info("Syncing contacts from radio")
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
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}")
|
||||
@@ -191,12 +281,23 @@ async def sync_contacts_from_radio() -> dict:
|
||||
contacts = result.payload
|
||||
count = 0
|
||||
|
||||
synced_keys: list[str] = []
|
||||
for public_key, contact_data in contacts.items():
|
||||
lower_key = public_key.lower()
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=True)
|
||||
ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
)
|
||||
synced_keys.append(lower_key)
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
contact_name=contact_data.get("adv_name"),
|
||||
log=logger,
|
||||
)
|
||||
count += 1
|
||||
|
||||
# Clear on_radio for contacts not found on the radio
|
||||
await ContactRepository.clear_on_radio_except(synced_keys)
|
||||
|
||||
logger.info("Synced %d contacts from radio", count)
|
||||
return {"synced": count}
|
||||
|
||||
@@ -204,23 +305,26 @@ async def sync_contacts_from_radio() -> dict:
|
||||
@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)."""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
# 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"}
|
||||
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])
|
||||
logger.info("Removing contact %s from radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.remove_contact(radio_contact)
|
||||
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}")
|
||||
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"}
|
||||
@@ -229,21 +333,22 @@ async def remove_contact_from_radio(public_key: str) -> dict:
|
||||
@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."""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key, "Contact not found in database")
|
||||
|
||||
# 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"}
|
||||
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])
|
||||
logger.info("Adding contact %s to radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
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}")
|
||||
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"}
|
||||
@@ -267,274 +372,26 @@ async def delete_contact(public_key: str) -> dict:
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
# Remove from radio if connected and contact is on radio
|
||||
if radio_manager.is_connected and radio_manager.meshcore:
|
||||
mc = radio_manager.meshcore
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
logger.info("Removing contact %s from radio before deletion", contact.public_key[:12])
|
||||
await mc.commands.remove_contact(radio_contact)
|
||||
if radio_manager.is_connected:
|
||||
async with radio_manager.radio_operation("delete_contact_from_radio") as mc:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
logger.info(
|
||||
"Removing contact %s from radio before deletion", contact.public_key[:12]
|
||||
)
|
||||
await mc.commands.remove_contact(radio_contact)
|
||||
|
||||
# Delete from database
|
||||
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"}
|
||||
|
||||
|
||||
@router.post("/{public_key}/telemetry", response_model=TelemetryResponse)
|
||||
async def request_telemetry(public_key: str, request: TelemetryRequest) -> TelemetryResponse:
|
||||
"""Request telemetry from a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). If not on the radio, it will be added.
|
||||
Uses login + status request with retry logic.
|
||||
"""
|
||||
mc = require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
# Verify it's a repeater
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
|
||||
)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"request_telemetry",
|
||||
meshcore=mc,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
):
|
||||
# Prepare connection (add/remove dance + login)
|
||||
await prepare_repeater_connection(mc, contact, request.password)
|
||||
|
||||
# Request status with retries
|
||||
logger.info("Requesting status from repeater %s", contact.public_key[:12])
|
||||
status = None
|
||||
for attempt in range(1, 4):
|
||||
logger.debug("Status request attempt %d/3", attempt)
|
||||
status = await mc.commands.req_status_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
if status:
|
||||
break
|
||||
logger.debug("Status request timeout, retrying...")
|
||||
|
||||
if not status:
|
||||
raise HTTPException(
|
||||
status_code=504, detail="No response from repeater after 3 attempts"
|
||||
)
|
||||
|
||||
logger.info("Received telemetry from %s: %s", contact.public_key[:12], status)
|
||||
|
||||
# Fetch neighbors (fetch_all_neighbours handles pagination)
|
||||
logger.info("Fetching neighbors from repeater %s", contact.public_key[:12])
|
||||
neighbors_data = None
|
||||
for attempt in range(1, 4):
|
||||
logger.debug("Neighbors request attempt %d/3", attempt)
|
||||
neighbors_data = await mc.commands.fetch_all_neighbours(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
if neighbors_data:
|
||||
break
|
||||
logger.debug("Neighbors request timeout, retrying...")
|
||||
|
||||
# Process neighbors - resolve pubkey prefixes to contact names
|
||||
neighbors: list[NeighborInfo] = []
|
||||
if neighbors_data and "neighbours" in neighbors_data:
|
||||
logger.info("Received %d neighbors", len(neighbors_data["neighbours"]))
|
||||
for n in neighbors_data["neighbours"]:
|
||||
pubkey_prefix = n.get("pubkey", "")
|
||||
# Try to resolve to a contact name from our database
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
neighbors.append(
|
||||
NeighborInfo(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
snr=n.get("snr", 0.0),
|
||||
last_heard_seconds=n.get("secs_ago", 0),
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch ACL
|
||||
logger.info("Fetching ACL from repeater %s", contact.public_key[:12])
|
||||
acl_data = None
|
||||
for attempt in range(1, 4):
|
||||
logger.debug("ACL request attempt %d/3", attempt)
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
if acl_data:
|
||||
break
|
||||
logger.debug("ACL request timeout, retrying...")
|
||||
|
||||
# Process ACL - resolve pubkey prefixes to contact names
|
||||
acl_entries: list[AclEntry] = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
logger.info("Received %d ACL entries", len(acl_data))
|
||||
for entry in acl_data:
|
||||
pubkey_prefix = entry.get("key", "")
|
||||
perm = entry.get("perm", 0)
|
||||
# Try to resolve to a contact name from our database
|
||||
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})"),
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch clock output (up to 2 attempts)
|
||||
logger.info("Fetching clock from repeater %s", contact.public_key[:12])
|
||||
clock_output: str | None = None
|
||||
for attempt in range(1, 3):
|
||||
logger.debug("Clock request attempt %d/2", attempt)
|
||||
try:
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, "clock")
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Clock command send error: %s", send_result.payload)
|
||||
continue
|
||||
|
||||
# Wait for response
|
||||
wait_result = await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=5.0)
|
||||
if wait_result is None:
|
||||
logger.debug("Clock request timeout, retrying...")
|
||||
continue
|
||||
|
||||
response_event = await mc.commands.get_msg()
|
||||
if response_event.type == EventType.ERROR:
|
||||
logger.debug("Clock get_msg error: %s", response_event.payload)
|
||||
continue
|
||||
|
||||
clock_output = response_event.payload.get("text", "")
|
||||
logger.info("Received clock output: %s", clock_output)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Clock request exception: %s", e)
|
||||
continue
|
||||
|
||||
if clock_output is None:
|
||||
clock_output = "Unable to fetch `clock` output (repeater did not respond)"
|
||||
|
||||
# Convert raw telemetry to response format
|
||||
# bat is in mV, convert to V (e.g., 3775 -> 3.775)
|
||||
|
||||
return TelemetryResponse(
|
||||
pubkey_prefix=status.get("pubkey_pre", contact.public_key[:12]),
|
||||
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),
|
||||
neighbors=neighbors,
|
||||
acl=acl_entries,
|
||||
clock_output=clock_output,
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
The contact must be a repeater (type=2). The user must have already logged in
|
||||
via the telemetry 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
|
||||
"""
|
||||
mc = require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
# Verify it's a repeater
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
|
||||
)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"send_repeater_command",
|
||||
meshcore=mc,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
):
|
||||
# Add contact to radio with path from DB
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
# 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 (MESSAGES_WAITING event, then get_msg)
|
||||
try:
|
||||
wait_result = await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=10.0)
|
||||
|
||||
if wait_result is None:
|
||||
# Timeout - no response received
|
||||
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)",
|
||||
)
|
||||
|
||||
response_event = await mc.commands.get_msg()
|
||||
|
||||
if response_event.type == EventType.ERROR:
|
||||
return CommandResponse(
|
||||
command=request.command, response=f"(error: {response_event.payload})"
|
||||
)
|
||||
|
||||
# Extract the response text and timestamp from the payload
|
||||
response_text = response_event.payload.get("text", str(response_event.payload))
|
||||
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,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error waiting for response: %s", e)
|
||||
return CommandResponse(
|
||||
command=request.command, response=f"(error waiting for response: {e})"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/trace", response_model=TraceResponse)
|
||||
async def request_trace(public_key: str) -> TraceResponse:
|
||||
"""Send a single-hop trace to a contact and wait for the result.
|
||||
@@ -543,7 +400,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
(no intermediate repeaters). The radio firmware requires at least one
|
||||
node in the path.
|
||||
"""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
@@ -553,9 +410,9 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
|
||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||
# from the reader loop, not via get_msg().
|
||||
async with radio_manager.radio_operation("request_trace", pause_polling=True):
|
||||
async with radio_manager.radio_operation("request_trace", pause_polling=True) as mc:
|
||||
# Ensure contact is on radio so the trace can reach them
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info(
|
||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||
@@ -593,3 +450,51 @@ 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}/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)
|
||||
|
||||
route_text = request.route.strip()
|
||||
if route_text == "":
|
||||
await ContactRepository.clear_routing_override(contact.public_key)
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info(
|
||||
"Cleared routing override and reset learned path to flood 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:
|
||||
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
|
||||
|
||||
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:
|
||||
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}
|
||||
|
||||
326
app/routers/fanout.py
Normal file
326
app/routers/fanout.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""REST API for fanout config CRUD."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
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"}
|
||||
|
||||
_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_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)."""
|
||||
code = config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
|
||||
try:
|
||||
compile(code, "<bot_code>", "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
|
||||
|
||||
|
||||
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 _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 == "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, 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}
|
||||
|
||||
|
||||
@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" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
|
||||
# Only validate config when creating as enabled — disabled configs
|
||||
# are drafts the user hasn't finished configuring yet.
|
||||
if body.enabled:
|
||||
if body.type == "mqtt_private":
|
||||
_validate_mqtt_private_config(body.config)
|
||||
elif body.type == "mqtt_community":
|
||||
_validate_mqtt_community_config(body.config)
|
||||
elif body.type == "bot":
|
||||
_validate_bot_config(body.config)
|
||||
elif body.type == "webhook":
|
||||
_validate_webhook_config(body.config)
|
||||
elif body.type == "apprise":
|
||||
_validate_apprise_config(body.config)
|
||||
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type=body.type,
|
||||
name=body.name,
|
||||
config=body.config,
|
||||
scope=scope,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
|
||||
# Start the module if enabled
|
||||
if cfg["enabled"]:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
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" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
|
||||
kwargs = {}
|
||||
if body.name is not None:
|
||||
kwargs["name"] = body.name
|
||||
if body.enabled is not None:
|
||||
kwargs["enabled"] = body.enabled
|
||||
if body.config is not None:
|
||||
kwargs["config"] = body.config
|
||||
if body.scope is not None:
|
||||
kwargs["scope"] = _enforce_scope(existing["type"], body.scope)
|
||||
|
||||
# Validate config when the result will be enabled
|
||||
will_be_enabled = body.enabled if body.enabled is not None else existing["enabled"]
|
||||
if will_be_enabled:
|
||||
config_to_validate = body.config if body.config is not None else existing["config"]
|
||||
if existing["type"] == "mqtt_private":
|
||||
_validate_mqtt_private_config(config_to_validate)
|
||||
elif existing["type"] == "mqtt_community":
|
||||
_validate_mqtt_community_config(config_to_validate)
|
||||
elif existing["type"] == "bot":
|
||||
_validate_bot_config(config_to_validate)
|
||||
elif existing["type"] == "webhook":
|
||||
_validate_webhook_config(config_to_validate)
|
||||
elif existing["type"] == "apprise":
|
||||
_validate_apprise_config(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
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
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
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.remove_config(config_id)
|
||||
await FanoutConfigRepository.delete(config_id)
|
||||
|
||||
logger.info("Deleted fanout config %s", config_id)
|
||||
return {"deleted": True}
|
||||
@@ -1,11 +1,12 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
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
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
@@ -13,9 +14,12 @@ router = APIRouter(tags=["health"])
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
radio_initializing: bool = False
|
||||
connection_info: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanout_statuses: dict[str, dict[str, str]] = {}
|
||||
bots_disabled: bool = False
|
||||
|
||||
|
||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||
@@ -33,12 +37,36 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
except RuntimeError:
|
||||
pass # Database not connected
|
||||
|
||||
# Fanout module statuses
|
||||
fanout_statuses: dict[str, Any] = {}
|
||||
try:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
fanout_statuses = fanout_manager.get_statuses()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
|
||||
if not isinstance(setup_in_progress, bool):
|
||||
setup_in_progress = False
|
||||
|
||||
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
|
||||
if not isinstance(setup_complete, bool):
|
||||
setup_complete = radio_connected
|
||||
if not radio_connected:
|
||||
setup_complete = False
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
|
||||
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,
|
||||
"connection_info": connection_info,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"fanout_statuses": fanout_statuses,
|
||||
"bots_disabled": settings.disable_bots,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
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."""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
# First check our database for the contact
|
||||
from app.repository import ContactRepository
|
||||
@@ -64,118 +109,36 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
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"):
|
||||
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",
|
||||
return await send_direct_message_to_contact(
|
||||
contact=db_contact,
|
||||
text=request.text,
|
||||
conversation_key=db_contact.public_key.lower(),
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
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,
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
# 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(),
|
||||
text=request.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
)
|
||||
|
||||
# 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
|
||||
TEMP_RADIO_SLOT = 0
|
||||
EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS = 3
|
||||
|
||||
|
||||
@router.post("/channel", response_model=Message)
|
||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
"""Send a message to a channel."""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.decoder import calculate_channel_hash
|
||||
from app.repository import AppSettingsRepository, ChannelRepository
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(request.channel_key)
|
||||
if not db_channel:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Channel {request.channel_key} not found in database"
|
||||
)
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
|
||||
# Convert channel key hex to bytes
|
||||
try:
|
||||
@@ -193,116 +156,71 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
TEMP_RADIO_SLOT,
|
||||
expected_hash,
|
||||
)
|
||||
channel_key_upper = request.channel_key.upper()
|
||||
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
|
||||
message_id: int | None = None
|
||||
now: int | None = None
|
||||
|
||||
async with radio_manager.radio_operation("send_channel_message"):
|
||||
# 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 the first successful send to avoid a race where
|
||||
# our own echo lands before persistence (especially with delayed duplicate sends).
|
||||
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",
|
||||
)
|
||||
|
||||
# Experimental: byte-perfect resend after a delay to improve delivery reliability.
|
||||
# This intentionally holds the radio operation lock for the full delay — it is an
|
||||
# opt-in experimental feature where blocking other radio operations is acceptable.
|
||||
if app_settings.experimental_channel_double_send:
|
||||
logger.debug(
|
||||
"Experimental channel double-send enabled; waiting %ds before byte-perfect duplicate",
|
||||
EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS,
|
||||
)
|
||||
await asyncio.sleep(EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS)
|
||||
duplicate_result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=request.text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if duplicate_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Experimental duplicate channel send failed: %s", duplicate_result.payload
|
||||
)
|
||||
|
||||
if message_id is None or now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
acked_count = await MessageRepository.get_ack_count(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,
|
||||
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,
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@router.post("/channel/{message_id}/resend")
|
||||
async def resend_channel_message(
|
||||
message_id: int,
|
||||
new_timestamp: bool = Query(default=False),
|
||||
) -> dict:
|
||||
"""Resend a channel message.
|
||||
|
||||
When new_timestamp=False (default): byte-perfect resend using the original timestamp.
|
||||
Only allowed within 30 seconds of the original send.
|
||||
|
||||
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()
|
||||
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
msg = await MessageRepository.get_by_id(message_id)
|
||||
if not msg:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
if not msg.outgoing:
|
||||
raise HTTPException(status_code=400, detail="Can only resend outgoing messages")
|
||||
|
||||
if msg.type != "CHAN":
|
||||
raise HTTPException(status_code=400, detail="Can only resend channel messages")
|
||||
|
||||
if msg.sender_timestamp is None:
|
||||
raise HTTPException(status_code=400, detail="Message has no timestamp")
|
||||
|
||||
# Byte-perfect resend enforces the 30s window; new-timestamp resend does not
|
||||
if not new_timestamp:
|
||||
elapsed = int(time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)")
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||
if not db_channel:
|
||||
raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
from sqlite3 import OperationalError
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
@@ -70,7 +71,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:
|
||||
@@ -236,8 +238,12 @@ async def decrypt_historical_packets(
|
||||
|
||||
|
||||
class MaintenanceRequest(BaseModel):
|
||||
prune_undecrypted_days: int = Field(
|
||||
ge=1, description="Delete undecrypted packets older than this many days"
|
||||
prune_undecrypted_days: int | None = Field(
|
||||
default=None, ge=1, description="Delete undecrypted packets older than this many days"
|
||||
)
|
||||
purge_linked_raw_packets: bool = Field(
|
||||
default=False,
|
||||
description="Delete raw packets already linked to a stored message",
|
||||
)
|
||||
|
||||
|
||||
@@ -249,22 +255,44 @@ class MaintenanceResult(BaseModel):
|
||||
@router.post("/maintenance", response_model=MaintenanceResult)
|
||||
async def run_maintenance(request: MaintenanceRequest) -> MaintenanceResult:
|
||||
"""
|
||||
Clean up old undecrypted packets and reclaim disk space.
|
||||
Run packet maintenance tasks and reclaim disk space.
|
||||
|
||||
- Deletes undecrypted packets older than the specified number of days
|
||||
- Optionally deletes undecrypted packets older than the specified number of days
|
||||
- Optionally deletes raw packets already linked to stored messages
|
||||
- Runs VACUUM to reclaim disk space
|
||||
"""
|
||||
logger.info(
|
||||
"Running maintenance: pruning packets older than %d days", request.prune_undecrypted_days
|
||||
)
|
||||
deleted = 0
|
||||
|
||||
# Prune old undecrypted packets
|
||||
deleted = await RawPacketRepository.prune_old_undecrypted(request.prune_undecrypted_days)
|
||||
logger.info("Deleted %d old undecrypted packets", deleted)
|
||||
if request.prune_undecrypted_days is not None:
|
||||
logger.info(
|
||||
"Running maintenance: pruning undecrypted packets older than %d days",
|
||||
request.prune_undecrypted_days,
|
||||
)
|
||||
pruned_undecrypted = await RawPacketRepository.prune_old_undecrypted(
|
||||
request.prune_undecrypted_days
|
||||
)
|
||||
deleted += pruned_undecrypted
|
||||
logger.info("Deleted %d old undecrypted packets", pruned_undecrypted)
|
||||
|
||||
# Run VACUUM to reclaim space on a dedicated connection
|
||||
async with aiosqlite.connect(db.db_path) as vacuum_conn:
|
||||
await vacuum_conn.executescript("VACUUM;")
|
||||
logger.info("Database vacuumed")
|
||||
if request.purge_linked_raw_packets:
|
||||
logger.info("Running maintenance: purging raw packets linked to stored messages")
|
||||
purged_linked = await RawPacketRepository.purge_linked_to_messages()
|
||||
deleted += purged_linked
|
||||
logger.info("Deleted %d linked raw packets", purged_linked)
|
||||
|
||||
return MaintenanceResult(packets_deleted=deleted, vacuumed=True)
|
||||
# Run VACUUM to reclaim space on a dedicated connection.
|
||||
# VACUUM requires exclusive access — if the main connection is actively
|
||||
# writing (background sync, message processing, etc.) it fails with
|
||||
# SQLITE_BUSY. This is expected; we just report vacuumed=False.
|
||||
vacuumed = False
|
||||
try:
|
||||
async with aiosqlite.connect(db.db_path) as vacuum_conn:
|
||||
await vacuum_conn.executescript("VACUUM;")
|
||||
vacuumed = True
|
||||
logger.info("Database vacuumed")
|
||||
except OperationalError as e:
|
||||
logger.warning("VACUUM skipped (database busy): %s", e)
|
||||
except Exception as e:
|
||||
logger.error("VACUUM failed unexpectedly: %s", e)
|
||||
|
||||
return MaintenanceResult(packets_deleted=deleted, vacuumed=vacuumed)
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import logging
|
||||
|
||||
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.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> None:
|
||||
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")
|
||||
bw: float = Field(description="Bandwidth in kHz")
|
||||
@@ -28,6 +44,12 @@ 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"
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -36,6 +58,12 @@ 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)",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
@@ -64,52 +92,29 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=RadioConfigResponse)
|
||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
"""Update radio configuration. Only provided fields will be updated."""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
async with radio_manager.radio_operation("update_radio_config"):
|
||||
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,
|
||||
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||
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()
|
||||
|
||||
# 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 +122,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."""
|
||||
mc = require_connected()
|
||||
require_connected()
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(update.private_key)
|
||||
@@ -125,12 +130,17 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for private key") from None
|
||||
|
||||
logger.info("Importing private key")
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
async with radio_manager.radio_operation("import_private_key") as mc:
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to import private key: {result.payload}"
|
||||
)
|
||||
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"}
|
||||
|
||||
@@ -149,7 +159,8 @@ async def send_advertisement() -> dict:
|
||||
require_connected()
|
||||
|
||||
logger.info("Sending flood advertisement")
|
||||
success = await do_send_advertisement(force=True)
|
||||
async with radio_manager.radio_operation("manual_advertisement") as mc:
|
||||
success = await do_send_advertisement(mc, force=True)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to send advertisement")
|
||||
@@ -157,6 +168,32 @@ async def send_advertisement() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _attempt_reconnect() -> dict:
|
||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "Reconnection already in progress",
|
||||
"connected": False,
|
||||
}
|
||||
|
||||
try:
|
||||
success = await _reconnect_and_prepare(broadcast_on_success=True)
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed after reconnect")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
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("/reboot")
|
||||
async def reboot_radio() -> dict:
|
||||
"""Reboot the radio, or reconnect if not currently connected.
|
||||
@@ -164,34 +201,17 @@ async def reboot_radio() -> dict:
|
||||
If connected: sends reboot command, connection will temporarily drop and auto-reconnect.
|
||||
If not connected: attempts to reconnect (same as /reconnect endpoint).
|
||||
"""
|
||||
# If connected, send reboot command
|
||||
if radio_manager.is_connected and radio_manager.meshcore:
|
||||
if radio_manager.is_connected:
|
||||
logger.info("Rebooting radio")
|
||||
await radio_manager.meshcore.commands.reboot()
|
||||
async with radio_manager.radio_operation("reboot_radio") as mc:
|
||||
await mc.commands.reboot()
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Reboot command sent. Radio will reconnect automatically.",
|
||||
}
|
||||
|
||||
# Not connected - attempt to reconnect
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "Reconnection already in progress",
|
||||
"connected": False,
|
||||
}
|
||||
|
||||
logger.info("Radio not connected, attempting reconnect")
|
||||
success = await radio_manager.reconnect()
|
||||
|
||||
if success:
|
||||
await radio_manager.post_connect_setup()
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
return await _attempt_reconnect()
|
||||
|
||||
|
||||
@router.post("/reconnect")
|
||||
@@ -203,23 +223,19 @@ async def reconnect_radio() -> dict:
|
||||
or power-cycled.
|
||||
"""
|
||||
if radio_manager.is_connected:
|
||||
return {"status": "ok", "message": "Already connected", "connected": True}
|
||||
if radio_manager.is_setup_complete:
|
||||
return {"status": "ok", "message": "Already connected", "connected": True}
|
||||
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "Reconnection already in progress",
|
||||
"connected": False,
|
||||
}
|
||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||
try:
|
||||
await _prepare_connected(broadcast_on_success=True)
|
||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
|
||||
logger.info("Manual reconnect requested")
|
||||
success = await radio_manager.reconnect()
|
||||
|
||||
if success:
|
||||
await radio_manager.post_connect_setup()
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
return await _attempt_reconnect()
|
||||
|
||||
@@ -3,25 +3,40 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import UnreadCounts
|
||||
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"])
|
||||
|
||||
|
||||
@router.get("/unreads", response_model=UnreadCounts)
|
||||
async def get_unreads(
|
||||
name: str | None = Query(default=None, description="User's name for @mention detection"),
|
||||
) -> UnreadCounts:
|
||||
async def get_unreads() -> UnreadCounts:
|
||||
"""Get unread counts, mention flags, and last message times for all conversations.
|
||||
|
||||
Computes unread counts server-side using last_read_at timestamps on
|
||||
channels and contacts, avoiding the need to fetch bulk messages.
|
||||
The radio's own name is sourced directly from the connected radio
|
||||
for @mention detection.
|
||||
"""
|
||||
data = await MessageRepository.get_unread_counts(name)
|
||||
name: str | None = None
|
||||
mc = radio_manager.meshcore
|
||||
if mc and mc.self_info:
|
||||
name = mc.self_info.get("name") or None
|
||||
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)
|
||||
|
||||
|
||||
@@ -30,7 +45,7 @@ async def mark_all_read() -> dict:
|
||||
"""Mark all contacts and channels as read.
|
||||
|
||||
Updates last_read_at to current timestamp for all contacts and channels
|
||||
in a single database transaction.
|
||||
using two repository updates (same timestamp value across both tables).
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
|
||||
510
app/routers/repeaters.py
Normal file
510
app/routers/repeaters.py
Normal file
@@ -0,0 +1,510 @@
|
||||
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,
|
||||
CommandRequest,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
LppSensor,
|
||||
NeighborInfo,
|
||||
RepeaterAclResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
RepeaterLoginRequest,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
)
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ACL permission level names
|
||||
ACL_PERMISSION_NAMES = {
|
||||
0: "Guest",
|
||||
1: "Read-only",
|
||||
2: "Read-write",
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _require_repeater(contact: Contact) -> None:
|
||||
"""Raise 400 if contact is not a repeater."""
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Granular repeater endpoints — one attempt, no server-side retries.
|
||||
# Frontend manages retry logic for better UX control.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_login",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await prepare_repeater_connection(mc, contact, request.password)
|
||||
|
||||
return RepeaterLoginResponse(status="ok")
|
||||
|
||||
|
||||
@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()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_status", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
# Ensure contact is on radio for routing
|
||||
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 repeater")
|
||||
|
||||
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}/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()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_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 repeater")
|
||||
|
||||
sensors: list[LppSensor] = []
|
||||
for entry in telemetry:
|
||||
channel = entry.get("channel", 0)
|
||||
type_name = str(entry.get("type", "unknown"))
|
||||
value = entry.get("value", 0)
|
||||
sensors.append(LppSensor(channel=channel, type_name=type_name, value=value))
|
||||
|
||||
return RepeaterLppTelemetryResponse(sensors=sensors)
|
||||
|
||||
|
||||
@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()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_neighbors", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
# Ensure contact is on radio for routing
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
neighbors_data = await mc.commands.fetch_all_neighbours(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
neighbors: list[NeighborInfo] = []
|
||||
if neighbors_data and "neighbours" in neighbors_data:
|
||||
for n in neighbors_data["neighbours"]:
|
||||
pubkey_prefix = n.get("pubkey", "")
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
neighbors.append(
|
||||
NeighborInfo(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
snr=n.get("snr", 0.0),
|
||||
last_heard_seconds=n.get("secs_ago", 0),
|
||||
)
|
||||
)
|
||||
|
||||
return RepeaterNeighborsResponse(neighbors=neighbors)
|
||||
|
||||
|
||||
@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()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_acl", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
# Ensure contact is on radio for routing
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
acl_entries: list[AclEntry] = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
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)
|
||||
|
||||
|
||||
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 repeater and collect responses.
|
||||
|
||||
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}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_radio_settings",
|
||||
[
|
||||
("ver", "firmware_version"),
|
||||
("get radio", "radio"),
|
||||
("get tx", "tx_power"),
|
||||
("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)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{public_key}/repeater/advert-intervals", response_model=RepeaterAdvertIntervalsResponse
|
||||
)
|
||||
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_advert_intervals",
|
||||
[
|
||||
("get advert.interval", "advert_interval"),
|
||||
("get flood.advert.interval", "flood_advert_interval"),
|
||||
],
|
||||
)
|
||||
return RepeaterAdvertIntervalsResponse(**results)
|
||||
|
||||
|
||||
@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()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_owner_info",
|
||||
[
|
||||
("get owner.info", "owner_info"),
|
||||
("get guest.password", "guest_password"),
|
||||
],
|
||||
)
|
||||
return RepeaterOwnerInfoResponse(**results)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
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, time <epoch_seconds>
|
||||
- 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,
|
||||
)
|
||||
@@ -1,51 +1,26 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings, BotConfig
|
||||
from app.models import AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
|
||||
),
|
||||
)
|
||||
experimental_channel_double_send: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Experimental: always send channel messages twice with a 3-second delay using "
|
||||
"identical timestamp bytes"
|
||||
"Configured radio contact capacity used for maintenance thresholds and "
|
||||
"background refill behavior"
|
||||
),
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool | None = Field(
|
||||
@@ -59,12 +34,28 @@ class AppSettingsUpdate(BaseModel):
|
||||
advert_interval: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
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)",
|
||||
)
|
||||
blocked_keys: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
)
|
||||
blocked_names: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
@@ -109,13 +100,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating max_radio_contacts to %d", update.max_radio_contacts)
|
||||
kwargs["max_radio_contacts"] = update.max_radio_contacts
|
||||
|
||||
if update.experimental_channel_double_send is not None:
|
||||
logger.info(
|
||||
"Updating experimental_channel_double_send to %s",
|
||||
update.experimental_channel_double_send,
|
||||
)
|
||||
kwargs["experimental_channel_double_send"] = update.experimental_channel_double_send
|
||||
|
||||
if update.auto_decrypt_dm_on_advert is not None:
|
||||
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
|
||||
@@ -125,16 +109,42 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
|
||||
|
||||
if update.advert_interval is not None:
|
||||
logger.info("Updating advert_interval to %d", update.advert_interval)
|
||||
kwargs["advert_interval"] = update.advert_interval
|
||||
# Enforce minimum 1-hour interval; 0 means disabled
|
||||
interval = update.advert_interval
|
||||
if 0 < interval < 3600:
|
||||
interval = 3600
|
||||
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
|
||||
|
||||
# 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:
|
||||
return await AppSettingsRepository.update(**kwargs)
|
||||
result = await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
# Apply flood scope to radio immediately if changed
|
||||
if flood_scope_changed:
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
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()
|
||||
|
||||
@@ -147,10 +157,33 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
|
||||
if is_favorited:
|
||||
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
|
||||
return await AppSettingsRepository.remove_favorite(request.type, request.id)
|
||||
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
|
||||
else:
|
||||
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
|
||||
return await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
result = await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
|
||||
# When a contact is newly favorited, load just that contact to the radio
|
||||
# immediately so DM ACK support does not wait for the next maintenance cycle.
|
||||
if request.type == "contact" and not is_favorited:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@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("/migrate", response_model=MigratePreferencesResponse)
|
||||
|
||||
12
app/routers/statistics.py
Normal file
12
app/routers/statistics.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import StatisticsResponse
|
||||
from app.repository import StatisticsRepository
|
||||
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_statistics() -> StatisticsResponse:
|
||||
data = await StatisticsRepository.get_all()
|
||||
return StatisticsResponse(**data)
|
||||
@@ -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__)
|
||||
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend service-layer helpers."""
|
||||
115
app/services/contact_reconciliation.py
Normal file
115
app/services/contact_reconciliation.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Shared contact/message reconciliation helpers."""
|
||||
|
||||
import logging
|
||||
|
||||
from app.repository import ContactNameHistoryRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
43
app/services/dm_ack_tracker.py
Normal file
43
app/services/dm_ack_tracker.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared pending ACK tracking for outgoing direct messages."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PendingAck = tuple[int, float, int]
|
||||
|
||||
_pending_acks: dict[str, PendingAck] = {}
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track an expected ACK code for an outgoing 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 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)
|
||||
|
||||
|
||||
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
|
||||
360
app/services/message_send.py
Normal file
360
app/services/message_send.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Shared send/resend orchestration for outgoing messages."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
||||
from app.services.messages import (
|
||||
build_message_model,
|
||||
create_outgoing_channel_message,
|
||||
create_outgoing_direct_message,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
TrackAckFn = Callable[[str, int, int], None]
|
||||
NowFn = Callable[[], float]
|
||||
|
||||
|
||||
async def send_channel_message_with_effective_scope(
|
||||
*,
|
||||
mc,
|
||||
channel,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
timestamp_bytes: bytes,
|
||||
action_label: str,
|
||||
temp_radio_slot: int,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
||||
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}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=temp_radio_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
temp_radio_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
)
|
||||
|
||||
return await mc.commands.send_chan_msg(
|
||||
chan=temp_radio_slot,
|
||||
msg=text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def send_direct_message_to_contact(
|
||||
*,
|
||||
contact,
|
||||
text: str,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
track_pending_ack_fn: TrackAckFn,
|
||||
now_fn: NowFn,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
) -> Any:
|
||||
"""Send a direct message and persist/broadcast the outgoing row."""
|
||||
contact_data = contact.to_radio_dict()
|
||||
contact_ensured_on_radio = False
|
||||
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)
|
||||
else:
|
||||
contact_ensured_on_radio = True
|
||||
|
||||
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not cached_contact:
|
||||
cached_contact = contact_data
|
||||
else:
|
||||
contact_ensured_on_radio = True
|
||||
|
||||
logger.info("Sending direct message to %s", contact.public_key[:12])
|
||||
now = int(now_fn())
|
||||
result = await mc.commands.send_msg(
|
||||
dst=cached_contact,
|
||||
msg=text,
|
||||
timestamp=now,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
if contact_ensured_on_radio and not contact.on_radio:
|
||||
await contact_repository.set_on_radio(contact.public_key.lower(), True)
|
||||
|
||||
message = await create_outgoing_direct_message(
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
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",
|
||||
)
|
||||
|
||||
await contact_repository.update_last_contacted(contact.public_key.lower(), now)
|
||||
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout: int = result.payload.get("suggested_timeout", 10000)
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack_fn(ack_code, message.id, suggested_timeout)
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message.id)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
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."""
|
||||
message_id: int | None = None
|
||||
now: int | None = None
|
||||
radio_name = ""
|
||||
our_public_key: str | None = None
|
||||
text_with_sender = text
|
||||
|
||||
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])
|
||||
|
||||
now = int(now_fn())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
key_bytes=key_bytes,
|
||||
text=text,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="sending message",
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
outgoing_message = await create_outgoing_channel_message(
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if outgoing_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
message_id = outgoing_message.id
|
||||
|
||||
if message_id is None or now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing 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=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
paths=paths,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
"""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
|
||||
|
||||
now: int | None = None
|
||||
if new_timestamp:
|
||||
now = int(now_fn())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
else:
|
||||
timestamp_bytes = message.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
resend_public_key: str | None = None
|
||||
radio_name = ""
|
||||
|
||||
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}: ") :]
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="resending message",
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to resend message: {result.payload}",
|
||||
)
|
||||
|
||||
if new_timestamp:
|
||||
if now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
new_message = await create_outgoing_channel_message(
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if new_message is None:
|
||||
logger.warning(
|
||||
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
|
||||
message.id,
|
||||
)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message.id,
|
||||
new_message.id,
|
||||
channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_message.id}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message.id, channel.name)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
447
app/services/messages.py
Normal file
447
app/services/messages.py
Normal file
@@ -0,0 +1,447 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Message, MessagePath
|
||||
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
|
||||
|
||||
def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | 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)]
|
||||
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,
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def broadcast_message_acked(
|
||||
*,
|
||||
message_id: int,
|
||||
ack_count: int,
|
||||
paths: list[MessagePath] | 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 [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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 handle_duplicate_message(
|
||||
*,
|
||||
packet_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_message_acked(
|
||||
message_id=existing_msg.id,
|
||||
ack_count=ack_count,
|
||||
paths=paths,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
|
||||
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),
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
),
|
||||
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,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int | None:
|
||||
"""Store and broadcast a decrypted direct message."""
|
||||
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 = their_public_key.lower()
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
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=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
outgoing,
|
||||
)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
broadcast_message(
|
||||
message=build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_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,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast a CONTACT_MSG_RECV fallback 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,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
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,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
sender_name=sender_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,
|
||||
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 = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
102
app/services/radio_commands.py
Normal file
102
app/services/radio_commands.py
Normal file
@@ -0,0 +1,102 @@
|
||||
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.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."
|
||||
)
|
||||
256
app/services/radio_lifecycle.py
Normal file
256
app/services/radio_lifecycle.py
Normal file
@@ -0,0 +1,256 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
mc = radio_manager.meshcore
|
||||
try:
|
||||
# Register event handlers (no radio I/O, just callback setup)
|
||||
register_event_handlers(mc)
|
||||
|
||||
# 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:
|
||||
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.path_hash_mode = 0
|
||||
radio_manager.path_hash_mode_supported = False
|
||||
try:
|
||||
device_query = await mc.commands.send_device_query()
|
||||
if device_query and "path_hash_mode" in device_query.payload:
|
||||
radio_manager.path_hash_mode = device_query.payload["path_hash_mode"]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
elif _captured_frame:
|
||||
# Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode
|
||||
raw = _captured_frame[-1]
|
||||
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||
if 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")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query path_hash_mode: %s", exc)
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
radio_manager._release_operation_lock("post_connect_setup")
|
||||
|
||||
# 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()
|
||||
|
||||
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) -> None:
|
||||
"""Finish setup for an already-connected radio and optionally broadcast health."""
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
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
|
||||
|
||||
radio_manager._last_connected = True
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
|
||||
|
||||
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
|
||||
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 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)
|
||||
91
app/services/radio_runtime.py
Normal file
91
app/services/radio_runtime.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""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) -> None:
|
||||
from app.services.radio_lifecycle import prepare_connected_radio
|
||||
|
||||
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()
|
||||
@@ -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)
|
||||
@@ -45,7 +46,7 @@ class WebSocketManager:
|
||||
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 +82,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,14 +93,27 @@ 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.
|
||||
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))
|
||||
|
||||
if realtime:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
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:
|
||||
"""Broadcast an error notification to all connected clients.
|
||||
|
||||
BIN
app_screenshot.png
Normal file
BIN
app_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
32
docker-compose.yaml
Normal file
32
docker-compose.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
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/ttyACM0:/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
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
install-links=true
|
||||
@@ -12,49 +12,126 @@ 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/
|
||||
├── App.tsx # App shell and orchestration
|
||||
├── main.tsx # React entry point (StrictMode, root render)
|
||||
├── 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
|
||||
├── wsEvents.ts # Typed WS event parsing / discriminated union
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── 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
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
│ ├── useConversationMessages.ts
|
||||
│ ├── useUnreadCounts.ts
|
||||
│ ├── useRepeaterMode.ts
|
||||
│ └── useAirtimeTracking.ts
|
||||
│ ├── index.ts # Central re-export of all hooks
|
||||
│ ├── 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
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/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
|
||||
│ ├── conversationState.ts
|
||||
│ ├── favorites.ts
|
||||
│ ├── messageParser.ts
|
||||
│ ├── pathUtils.ts
|
||||
│ ├── pubkey.ts
|
||||
│ └── contactAvatar.ts
|
||||
│ ├── urlHash.ts # Hash parsing and encoding
|
||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
||||
│ ├── messageParser.ts # Message text → rendered segments
|
||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
│ ├── 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
|
||||
│ └── theme.ts # Theme switching helpers
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
│ ├── Sidebar.tsx
|
||||
│ ├── ChatHeader.tsx # Conversation header (trace, favorite, delete)
|
||||
│ ├── MessageList.tsx
|
||||
│ ├── MessageInput.tsx
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SettingsModal.tsx
|
||||
│ ├── SearchView.tsx # Full-text message search pane
|
||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── MapView.tsx
|
||||
│ ├── VisualizerView.tsx
|
||||
│ ├── PacketVisualizer.tsx
|
||||
│ ├── PacketVisualizer3D.tsx
|
||||
│ ├── PathModal.tsx
|
||||
│ ├── PathRouteMap.tsx
|
||||
│ ├── CrackerPanel.tsx
|
||||
│ ├── BotCodeEditor.tsx
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ └── ui/
|
||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ ├── 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
|
||||
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
|
||||
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
|
||||
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
|
||||
│ │ └── RepeaterConsolePane.tsx # CLI console with history
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── types/
|
||||
│ ├── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
│ └── globals.d.ts # Global type declarations (__APP_VERSION__, __COMMIT_HASH__)
|
||||
└── test/
|
||||
├── setup.ts
|
||||
├── fixtures/websocket_events.json
|
||||
├── api.test.ts
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
@@ -63,45 +140,115 @@ frontend/src/
|
||||
├── messageCache.test.ts
|
||||
├── messageParser.test.ts
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── radioPresets.test.ts
|
||||
├── repeaterMode.test.ts
|
||||
├── rawPacketIdentity.test.ts
|
||||
├── repeaterDashboard.test.tsx
|
||||
├── repeaterFormatters.test.ts
|
||||
├── repeaterLogin.test.tsx
|
||||
├── repeaterMessageParsing.test.ts
|
||||
├── localLabel.test.ts
|
||||
├── messageInput.test.tsx
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
├── channelInfoKeyVisibility.test.tsx
|
||||
├── chatHeaderKeyVisibility.test.tsx
|
||||
├── searchView.test.tsx
|
||||
├── useConversationMessages.test.ts
|
||||
├── useRepeaterMode.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
├── useAppShell.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useRealtimeAppState.test.ts
|
||||
├── useUnreadCounts.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
├── websocket.test.ts
|
||||
└── setup.ts
|
||||
└── wsEvents.test.ts
|
||||
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### State ownership
|
||||
|
||||
`App.tsx` orchestrates high-level state (health, config, contacts/channels, active conversation, UI flags).
|
||||
Specialized logic is delegated to hooks:
|
||||
- `useConversationMessages`: fetch, pagination, dedup/update helpers
|
||||
`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
|
||||
|
||||
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
|
||||
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
|
||||
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
|
||||
- `useConversationMessages`: conversation switch loading, cache restore, jump-target loading, pagination, dedup/update helpers, and pending ACK buffering
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useRepeaterMode`: repeater login/command workflow
|
||||
- `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
|
||||
- repeater dashboard
|
||||
- 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` resets form state on close. The component instance persists across open/close cycles for smooth animations.
|
||||
|
||||
### Message behavior
|
||||
|
||||
- Outgoing sends are optimistic in UI and persisted server-side.
|
||||
- Outgoing sends are added to UI after the send API returns (not pre-send optimistic insertion), then persisted server-side.
|
||||
- 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`.
|
||||
|
||||
### 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).
|
||||
|
||||
### Radio settings behavior
|
||||
|
||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||
- 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 returns a typed discriminated union for known events and a centralized `unknown` fallback.
|
||||
- Event handlers: `health`, `message`, `contact`, `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`)
|
||||
|
||||
@@ -110,6 +257,7 @@ Supported routes:
|
||||
- `#map`
|
||||
- `#map/focus/{pubkey_or_prefix}`
|
||||
- `#visualizer`
|
||||
- `#search`
|
||||
- `#channel/{channelKey}`
|
||||
- `#channel/{channelKey}/{label}`
|
||||
- `#contact/{publicKey}`
|
||||
@@ -149,7 +297,6 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
|
||||
`AppSettings` currently includes:
|
||||
- `max_radio_contacts`
|
||||
- `experimental_channel_double_send`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
@@ -157,15 +304,70 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`
|
||||
|
||||
## Repeater Mode
|
||||
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`.
|
||||
|
||||
For repeater contacts (`type=2`):
|
||||
1. Telemetry/login phase (`POST /api/contacts/{key}/telemetry`)
|
||||
2. Command phase (`POST /api/contacts/{key}/command`)
|
||||
`HealthStatus` includes `fanout_statuses: Record<string, FanoutStatusEntry>` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`.
|
||||
|
||||
CLI responses are rendered as local-only messages (not persisted to DB).
|
||||
`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.
|
||||
|
||||
## 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`:
|
||||
|
||||
- Header: avatar, name, public key, type badge, on-radio badge
|
||||
- Info grid: last seen, first heard, last contacted, distance, hops
|
||||
- GPS location (clickable → map)
|
||||
- Favorite toggle
|
||||
- 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)
|
||||
- Advert observation rate
|
||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||
- Recent advert paths
|
||||
|
||||
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`), `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).
|
||||
|
||||
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
|
||||
|
||||
**Console pane**: Full CLI access via the same command endpoint. History is ephemeral (not persisted to DB).
|
||||
|
||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -180,6 +382,14 @@ Do not rely on old class-only layout assumptions.
|
||||
|
||||
## Testing
|
||||
|
||||
Run all quality checks (backend + frontend) from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
|
||||
Or run frontend checks individually:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:run
|
||||
@@ -192,6 +402,12 @@ When touching cross-layer contracts, also run backend tests from repo root:
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
### 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.
|
||||
|
||||
## Editing Checklist
|
||||
|
||||
1. If API/WS payloads change, update `types.ts`, handlers, and tests.
|
||||
|
||||
@@ -6,13 +6,50 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MCTerm" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<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" />
|
||||
<script>
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
// fetches before it discovers and starts downloading the JS bundle.
|
||||
// React hooks consume the promises via window.__prefetch.
|
||||
var fetchJsonOrThrow = function(url) {
|
||||
return fetch(url).then(function(response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text().then(function(rawError) {
|
||||
var message = rawError || response.statusText || ('HTTP ' + response.status);
|
||||
try {
|
||||
var parsed = JSON.parse(rawError);
|
||||
if (parsed && typeof parsed.detail === 'string' && parsed.detail.length > 0) {
|
||||
message = parsed.detail;
|
||||
}
|
||||
} catch {
|
||||
// Keep raw text fallback when body is not JSON.
|
||||
}
|
||||
throw new Error('Prefetch failed for ' + url + ': ' + message);
|
||||
});
|
||||
}).catch(function(err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
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'),
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
21
frontend/lib/meshcore-decoder/LICENSE.md
Normal file
21
frontend/lib/meshcore-decoder/LICENSE.md
Normal file
@@ -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.
|
||||
453
frontend/lib/meshcore-decoder/README.md
Normal file
453
frontend/lib/meshcore-decoder/README.md
Normal file
@@ -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
frontend/lib/meshcore-decoder/dist/cli.d.ts
vendored
Normal file
3
frontend/lib/meshcore-decoder/dist/cli.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
//# sourceMappingURL=cli.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/cli.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/cli.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
||||
409
frontend/lib/meshcore-decoder/dist/cli.js
vendored
Normal file
409
frontend/lib/meshcore-decoder/dist/cli.js
vendored
Normal file
@@ -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
|
||||
1
frontend/lib/meshcore-decoder/dist/cli.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/cli.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
15
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts
vendored
Normal file
15
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts
vendored
Normal file
@@ -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
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts.map
vendored
Normal file
@@ -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"}
|
||||
94
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js
vendored
Normal file
94
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js
vendored
Normal file
@@ -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
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js.map
vendored
Normal file
@@ -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"}
|
||||
48
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts
vendored
Normal file
48
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts
vendored
Normal file
@@ -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
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts.map
vendored
Normal file
@@ -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"}
|
||||
217
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js
vendored
Normal file
217
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js
vendored
Normal file
@@ -0,0 +1,217 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
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;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Ed25519SignatureVerifier = void 0;
|
||||
const ed25519 = __importStar(require("@noble/ed25519"));
|
||||
const hex_1 = require("../utils/hex");
|
||||
const orlp_ed25519_wasm_1 = require("./orlp-ed25519-wasm");
|
||||
// Cross-platform SHA-512 implementation
|
||||
async function sha512Hash(data) {
|
||||
// Browser environment - use Web Crypto API
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle) {
|
||||
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-512', data);
|
||||
return new Uint8Array(hashBuffer);
|
||||
}
|
||||
// Node.js environment - use crypto module
|
||||
if (typeof require !== 'undefined') {
|
||||
try {
|
||||
const { createHash } = require('crypto');
|
||||
return createHash('sha512').update(data).digest();
|
||||
}
|
||||
catch (error) {
|
||||
// Fallback for environments where require is not available
|
||||
}
|
||||
}
|
||||
throw new Error('No SHA-512 implementation available');
|
||||
}
|
||||
function sha512HashSync(data) {
|
||||
// Node.js environment - use crypto module
|
||||
if (typeof require !== 'undefined') {
|
||||
try {
|
||||
const { createHash } = require('crypto');
|
||||
return createHash('sha512').update(data).digest();
|
||||
}
|
||||
catch (error) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
// Browser environment fallback - use crypto-js for sync operation
|
||||
try {
|
||||
const CryptoJS = require('crypto-js');
|
||||
const wordArray = CryptoJS.lib.WordArray.create(data);
|
||||
const hash = CryptoJS.SHA512(wordArray);
|
||||
const hashBytes = new Uint8Array(64);
|
||||
// Convert CryptoJS hash to Uint8Array
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const word = hash.words[i] || 0;
|
||||
hashBytes[i * 4] = (word >>> 24) & 0xff;
|
||||
hashBytes[i * 4 + 1] = (word >>> 16) & 0xff;
|
||||
hashBytes[i * 4 + 2] = (word >>> 8) & 0xff;
|
||||
hashBytes[i * 4 + 3] = word & 0xff;
|
||||
}
|
||||
return hashBytes;
|
||||
}
|
||||
catch (error) {
|
||||
// Final fallback - this should not happen since crypto-js is a dependency
|
||||
throw new Error('No SHA-512 implementation available for synchronous operation');
|
||||
}
|
||||
}
|
||||
// Set up SHA-512 for @noble/ed25519
|
||||
ed25519.etc.sha512Async = sha512Hash;
|
||||
// Always set up sync version - @noble/ed25519 requires it
|
||||
// It will throw in browser environments, which @noble/ed25519 can handle
|
||||
try {
|
||||
ed25519.etc.sha512Sync = sha512HashSync;
|
||||
}
|
||||
catch (error) {
|
||||
console.debug('Could not set up synchronous SHA-512:', error);
|
||||
}
|
||||
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 async verifyAdvertisementSignature(publicKeyHex, signatureHex, timestamp, appDataHex) {
|
||||
try {
|
||||
// Convert hex strings to Uint8Arrays
|
||||
const publicKey = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
const signature = (0, hex_1.hexToBytes)(signatureHex);
|
||||
const appData = (0, hex_1.hexToBytes)(appDataHex);
|
||||
// Construct the signed message according to MeshCore format
|
||||
const message = this.constructAdvertSignedMessage(publicKeyHex, timestamp, appData);
|
||||
// Verify the signature using noble-ed25519
|
||||
return await ed25519.verify(signature, message, publicKey);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Ed25519 signature verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
static constructAdvertSignedMessage(publicKeyHex, timestamp, appData) {
|
||||
const publicKey = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
// Timestamp (4 bytes, little-endian)
|
||||
const timestampBytes = new Uint8Array(4);
|
||||
timestampBytes[0] = timestamp & 0xFF;
|
||||
timestampBytes[1] = (timestamp >> 8) & 0xFF;
|
||||
timestampBytes[2] = (timestamp >> 16) & 0xFF;
|
||||
timestampBytes[3] = (timestamp >> 24) & 0xFF;
|
||||
// Concatenate: public_key + timestamp + app_data
|
||||
const message = new Uint8Array(32 + 4 + appData.length);
|
||||
message.set(publicKey, 0);
|
||||
message.set(timestampBytes, 32);
|
||||
message.set(appData, 36);
|
||||
return message;
|
||||
}
|
||||
/**
|
||||
* Get a human-readable description of what was signed
|
||||
*/
|
||||
static getSignedMessageDescription(publicKeyHex, timestamp, appDataHex) {
|
||||
return `Public Key: ${publicKeyHex} + Timestamp: ${timestamp} (${new Date(timestamp * 1000).toISOString()}) + App Data: ${appDataHex}`;
|
||||
}
|
||||
/**
|
||||
* Get the hex representation of the signed message for debugging
|
||||
*/
|
||||
static getSignedMessageHex(publicKeyHex, timestamp, appDataHex) {
|
||||
const appData = (0, hex_1.hexToBytes)(appDataHex);
|
||||
const message = this.constructAdvertSignedMessage(publicKeyHex, timestamp, appData);
|
||||
return (0, hex_1.bytesToHex)(message);
|
||||
}
|
||||
/**
|
||||
* 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 async derivePublicKey(privateKeyHex) {
|
||||
try {
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
// Use the orlp/ed25519 WebAssembly implementation
|
||||
return await (0, orlp_ed25519_wasm_1.derivePublicKey)(privateKeyHex);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to derive public key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
// Note: WASM operations are async, so this sync version throws an error
|
||||
throw new Error('Synchronous key derivation not supported with WASM. Use derivePublicKey() instead.');
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to derive public key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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 async validateKeyPair(privateKeyHex, expectedPublicKeyHex) {
|
||||
try {
|
||||
return await (0, orlp_ed25519_wasm_1.validateKeyPair)(privateKeyHex, expectedPublicKeyHex);
|
||||
}
|
||||
catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Ed25519SignatureVerifier = Ed25519SignatureVerifier;
|
||||
//# sourceMappingURL=ed25519-verifier.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
23
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts
vendored
Normal file
23
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CryptoKeyStore } from '../types/crypto';
|
||||
export declare class MeshCoreKeyStore implements CryptoKeyStore {
|
||||
nodeKeys: Map<string, string>;
|
||||
private channelHashToKeys;
|
||||
constructor(initialKeys?: {
|
||||
channelSecrets?: string[];
|
||||
nodeKeys?: Record<string, string>;
|
||||
});
|
||||
addNodeKey(publicKey: string, privateKey: string): void;
|
||||
hasChannelKey(channelHash: string): boolean;
|
||||
hasNodeKey(publicKey: string): boolean;
|
||||
/**
|
||||
* Get all channel keys that match the given channel hash (handles collisions)
|
||||
*/
|
||||
getChannelKeys(channelHash: string): string[];
|
||||
getNodeKey(publicKey: string): string | undefined;
|
||||
/**
|
||||
* Add channel keys by secret keys (new simplified API)
|
||||
* Automatically calculates channel hashes
|
||||
*/
|
||||
addChannelSecrets(secretKeys: string[]): void;
|
||||
}
|
||||
//# sourceMappingURL=key-manager.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"key-manager.d.ts","sourceRoot":"","sources":["../../src/crypto/key-manager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGjD,qBAAa,gBAAiB,YAAW,cAAc;IAC9C,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAa;IAGjD,OAAO,CAAC,iBAAiB,CAA+B;gBAE5C,WAAW,CAAC,EAAE;QACxB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACnC;IAYD,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAKvD,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO;IAK3C,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAKtC;;OAEG;IACH,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE;IAK7C,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAKjD;;;OAGG;IACH,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI;CAW9C"}
|
||||
60
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js
vendored
Normal file
60
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MeshCoreKeyStore = void 0;
|
||||
const channel_crypto_1 = require("./channel-crypto");
|
||||
class MeshCoreKeyStore {
|
||||
constructor(initialKeys) {
|
||||
this.nodeKeys = new Map();
|
||||
// internal map for hash -> multiple keys (collision handling)
|
||||
this.channelHashToKeys = new Map();
|
||||
if (initialKeys?.channelSecrets) {
|
||||
this.addChannelSecrets(initialKeys.channelSecrets);
|
||||
}
|
||||
if (initialKeys?.nodeKeys) {
|
||||
Object.entries(initialKeys.nodeKeys).forEach(([pubKey, privKey]) => {
|
||||
this.addNodeKey(pubKey, privKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
addNodeKey(publicKey, privateKey) {
|
||||
const normalizedPubKey = publicKey.toUpperCase();
|
||||
this.nodeKeys.set(normalizedPubKey, privateKey);
|
||||
}
|
||||
hasChannelKey(channelHash) {
|
||||
const normalizedHash = channelHash.toLowerCase();
|
||||
return this.channelHashToKeys.has(normalizedHash);
|
||||
}
|
||||
hasNodeKey(publicKey) {
|
||||
const normalizedPubKey = publicKey.toUpperCase();
|
||||
return this.nodeKeys.has(normalizedPubKey);
|
||||
}
|
||||
/**
|
||||
* Get all channel keys that match the given channel hash (handles collisions)
|
||||
*/
|
||||
getChannelKeys(channelHash) {
|
||||
const normalizedHash = channelHash.toLowerCase();
|
||||
return this.channelHashToKeys.get(normalizedHash) || [];
|
||||
}
|
||||
getNodeKey(publicKey) {
|
||||
const normalizedPubKey = publicKey.toUpperCase();
|
||||
return this.nodeKeys.get(normalizedPubKey);
|
||||
}
|
||||
/**
|
||||
* Add channel keys by secret keys (new simplified API)
|
||||
* Automatically calculates channel hashes
|
||||
*/
|
||||
addChannelSecrets(secretKeys) {
|
||||
for (const secretKey of secretKeys) {
|
||||
const channelHash = channel_crypto_1.ChannelCrypto.calculateChannelHash(secretKey).toLowerCase();
|
||||
// Handle potential hash collisions
|
||||
if (!this.channelHashToKeys.has(channelHash)) {
|
||||
this.channelHashToKeys.set(channelHash, []);
|
||||
}
|
||||
this.channelHashToKeys.get(channelHash).push(secretKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.MeshCoreKeyStore = MeshCoreKeyStore;
|
||||
//# sourceMappingURL=key-manager.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"key-manager.js","sourceRoot":"","sources":["../../src/crypto/key-manager.ts"],"names":[],"mappings":";AAAA,mFAAmF;AACnF,cAAc;;;AAGd,qDAAiD;AAEjD,MAAa,gBAAgB;IAM3B,YAAY,WAGX;QARM,aAAQ,GAAwB,IAAI,GAAG,EAAE,CAAC;QAEjD,8DAA8D;QACtD,sBAAiB,GAAG,IAAI,GAAG,EAAoB,CAAC;QAMtD,IAAI,WAAW,EAAE,cAAc,EAAE,CAAC;YAChC,IAAI,CAAC,iBAAiB,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC;QACrD,CAAC;QAED,IAAI,WAAW,EAAE,QAAQ,EAAE,CAAC;YAC1B,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE;gBACjE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YACnC,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,UAAU,CAAC,SAAiB,EAAE,UAAkB;QAC9C,MAAM,gBAAgB,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QACjD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,EAAE,UAAU,CAAC,CAAC;IAClD,CAAC;IAED,aAAa,CAAC,WAAmB;QAC/B,MAAM,cAAc,GAAG,WAAW,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IACpD,CAAC;IAED,UAAU,CAAC,SAAiB;QAC1B,MAAM,gBAAgB,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAC7C,CAAC;IAED;;OAEG;IACH,cAAc,CAAC,WAAmB;QAChC,MAAM,cAAc,GAAG,WAAW,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC;IAC1D,CAAC;IAED,UAAU,CAAC,SAAiB;QAC1B,MAAM,gBAAgB,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAC7C,CAAC;IAED;;;OAGG;IACH,iBAAiB,CAAC,UAAoB;QACpC,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACnC,MAAM,WAAW,GAAG,8BAAa,CAAC,oBAAoB,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,CAAC;YAEhF,mCAAmC;YACnC,IAAI,CAAC,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,CAAC;gBAC7C,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;YAC9C,CAAC;YACD,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,WAAW,CAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC3D,CAAC;IACH,CAAC;CACF;AAhED,4CAgEC"}
|
||||
34
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts
vendored
Normal file
34
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Derive Ed25519 public key from private key using the exact orlp/ed25519 algorithm
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
export declare function derivePublicKey(privateKeyHex: string): Promise<string>;
|
||||
/**
|
||||
* Validate that a private key and public key pair match using orlp/ed25519
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format
|
||||
* @param expectedPublicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if the keys match, false otherwise
|
||||
*/
|
||||
export declare function validateKeyPair(privateKeyHex: string, expectedPublicKeyHex: string): Promise<boolean>;
|
||||
/**
|
||||
* Sign a message using Ed25519 with orlp/ed25519 implementation
|
||||
*
|
||||
* @param messageHex - Message to sign in hex format
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns 64-byte signature in hex format
|
||||
*/
|
||||
export declare function sign(messageHex: string, privateKeyHex: string, publicKeyHex: string): Promise<string>;
|
||||
/**
|
||||
* Verify an Ed25519 signature using orlp/ed25519 implementation
|
||||
*
|
||||
* @param signatureHex - 64-byte signature in hex format
|
||||
* @param messageHex - Message that was signed in hex format
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export declare function verify(signatureHex: string, messageHex: string, publicKeyHex: string): Promise<boolean>;
|
||||
//# sourceMappingURL=orlp-ed25519-wasm.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"orlp-ed25519-wasm.d.ts","sourceRoot":"","sources":["../../src/crypto/orlp-ed25519-wasm.ts"],"names":[],"mappings":"AAgBA;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAiC5E;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAoC3G;AAED;;;;;;;GAOG;AACH,wBAAsB,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuC3G;AAED;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAsC7G"}
|
||||
150
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js
vendored
Normal file
150
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
"use strict";
|
||||
// WebAssembly wrapper for orlp/ed25519 key derivation
|
||||
// This provides the exact orlp algorithm for JavaScript
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.derivePublicKey = derivePublicKey;
|
||||
exports.validateKeyPair = validateKeyPair;
|
||||
exports.sign = sign;
|
||||
exports.verify = verify;
|
||||
const hex_1 = require("../utils/hex");
|
||||
// Import the generated WASM module
|
||||
const OrlpEd25519 = require('../../lib/orlp-ed25519.js');
|
||||
/**
|
||||
* Get a fresh WASM instance
|
||||
* Loads a fresh instance each time because the WASM module could behave unpredictably otherwise
|
||||
*/
|
||||
async function getWasmInstance() {
|
||||
return await OrlpEd25519();
|
||||
}
|
||||
/**
|
||||
* Derive Ed25519 public key from private key using the exact orlp/ed25519 algorithm
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
async function derivePublicKey(privateKeyHex) {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
// Allocate memory buffers directly in WASM heap
|
||||
const privateKeyPtr = 1024; // Use fixed memory locations
|
||||
const publicKeyPtr = 1024 + 64;
|
||||
// Copy private key to WASM memory
|
||||
wasmModule.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
// Call the orlp key derivation function
|
||||
const result = wasmModule.ccall('orlp_derive_public_key', 'number', ['number', 'number'], [publicKeyPtr, privateKeyPtr]);
|
||||
if (result !== 0) {
|
||||
throw new Error('orlp key derivation failed: invalid private key');
|
||||
}
|
||||
// Read the public key from WASM memory
|
||||
const publicKeyBytes = new Uint8Array(32);
|
||||
publicKeyBytes.set(wasmModule.HEAPU8.subarray(publicKeyPtr, publicKeyPtr + 32));
|
||||
return (0, hex_1.bytesToHex)(publicKeyBytes);
|
||||
}
|
||||
/**
|
||||
* Validate that a private key and public key pair match using orlp/ed25519
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format
|
||||
* @param expectedPublicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if the keys match, false otherwise
|
||||
*/
|
||||
async function validateKeyPair(privateKeyHex, expectedPublicKeyHex) {
|
||||
try {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
const expectedPublicKeyBytes = (0, hex_1.hexToBytes)(expectedPublicKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
return false;
|
||||
}
|
||||
if (expectedPublicKeyBytes.length !== 32) {
|
||||
return false;
|
||||
}
|
||||
// Allocate memory buffers directly in WASM heap
|
||||
const privateKeyPtr = 2048; // Use different fixed memory locations
|
||||
const publicKeyPtr = 2048 + 64;
|
||||
// Copy keys to WASM memory
|
||||
wasmModule.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
wasmModule.HEAPU8.set(expectedPublicKeyBytes, publicKeyPtr);
|
||||
// Call the validation function (note: C function expects public_key first, then private_key)
|
||||
const result = wasmModule.ccall('orlp_validate_keypair', 'number', ['number', 'number'], [publicKeyPtr, privateKeyPtr]);
|
||||
return result === 1;
|
||||
}
|
||||
catch (error) {
|
||||
// Invalid hex strings or other errors should return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sign a message using Ed25519 with orlp/ed25519 implementation
|
||||
*
|
||||
* @param messageHex - Message to sign in hex format
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns 64-byte signature in hex format
|
||||
*/
|
||||
async function sign(messageHex, privateKeyHex, publicKeyHex) {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const messageBytes = (0, hex_1.hexToBytes)(messageHex);
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
const publicKeyBytes = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
if (publicKeyBytes.length !== 32) {
|
||||
throw new Error(`Invalid public key length: expected 32 bytes, got ${publicKeyBytes.length}`);
|
||||
}
|
||||
// Allocate memory buffers with large gaps to avoid conflicts with scratch space
|
||||
const messagePtr = 100000;
|
||||
const privateKeyPtr = 200000;
|
||||
const publicKeyPtr = 300000;
|
||||
const signaturePtr = 400000;
|
||||
// Copy data to WASM memory
|
||||
wasmModule.HEAPU8.set(messageBytes, messagePtr);
|
||||
wasmModule.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
wasmModule.HEAPU8.set(publicKeyBytes, publicKeyPtr);
|
||||
// Call orlp_sign
|
||||
wasmModule.ccall('orlp_sign', 'void', ['number', 'number', 'number', 'number', 'number'], [signaturePtr, messagePtr, messageBytes.length, publicKeyPtr, privateKeyPtr]);
|
||||
// Read signature
|
||||
const signatureBytes = new Uint8Array(64);
|
||||
signatureBytes.set(wasmModule.HEAPU8.subarray(signaturePtr, signaturePtr + 64));
|
||||
return (0, hex_1.bytesToHex)(signatureBytes);
|
||||
}
|
||||
/**
|
||||
* Verify an Ed25519 signature using orlp/ed25519 implementation
|
||||
*
|
||||
* @param signatureHex - 64-byte signature in hex format
|
||||
* @param messageHex - Message that was signed in hex format
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
async function verify(signatureHex, messageHex, publicKeyHex) {
|
||||
try {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const signatureBytes = (0, hex_1.hexToBytes)(signatureHex);
|
||||
const messageBytes = (0, hex_1.hexToBytes)(messageHex);
|
||||
const publicKeyBytes = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
if (signatureBytes.length !== 64) {
|
||||
return false;
|
||||
}
|
||||
if (publicKeyBytes.length !== 32) {
|
||||
return false;
|
||||
}
|
||||
// Allocate memory buffers with large gaps to avoid conflicts with scratch space
|
||||
const messagePtr = 500000;
|
||||
const signaturePtr = 600000;
|
||||
const publicKeyPtr = 700000;
|
||||
// Copy data to WASM memory
|
||||
wasmModule.HEAPU8.set(signatureBytes, signaturePtr);
|
||||
wasmModule.HEAPU8.set(messageBytes, messagePtr);
|
||||
wasmModule.HEAPU8.set(publicKeyBytes, publicKeyPtr);
|
||||
// Call the orlp verify function
|
||||
const result = wasmModule.ccall('orlp_verify', 'number', ['number', 'number', 'number', 'number'], [signaturePtr, messagePtr, messageBytes.length, publicKeyPtr]);
|
||||
return result === 1;
|
||||
}
|
||||
catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=orlp-ed25519-wasm.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"orlp-ed25519-wasm.js","sourceRoot":"","sources":["../../src/crypto/orlp-ed25519-wasm.ts"],"names":[],"mappings":";AAAA,sDAAsD;AACtD,wDAAwD;;AAqBxD,0CAiCC;AASD,0CAoCC;AAUD,oBAuCC;AAUD,wBAsCC;AAlMD,sCAAsD;AAEtD,mCAAmC;AACnC,MAAM,WAAW,GAAG,OAAO,CAAC,2BAA2B,CAAC,CAAC;AAEzD;;;GAGG;AACH,KAAK,UAAU,eAAe;IAC5B,OAAO,MAAM,WAAW,EAAE,CAAC;AAC7B,CAAC;AAED;;;;;GAKG;AACI,KAAK,UAAU,eAAe,CAAC,aAAqB;IACzD,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;IAE3C,MAAM,eAAe,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC;IAElD,IAAI,eAAe,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,sDAAsD,eAAe,CAAC,MAAM,EAAE,CAAC,CAAC;IAClG,CAAC;IAED,gDAAgD;IAChD,MAAM,aAAa,GAAG,IAAI,CAAC,CAAC,6BAA6B;IACzD,MAAM,YAAY,GAAG,IAAI,GAAG,EAAE,CAAC;IAE/B,kCAAkC;IAClC,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;IAEtD,wCAAwC;IACxC,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAC7B,wBAAwB,EACxB,QAAQ,EACR,CAAC,QAAQ,EAAE,QAAQ,CAAC,EACpB,CAAC,YAAY,EAAE,aAAa,CAAC,CAC9B,CAAC;IAEF,IAAI,MAAM,KAAK,CAAC,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAC;IACrE,CAAC;IAED,uCAAuC;IACvC,MAAM,cAAc,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IAC1C,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,GAAG,EAAE,CAAC,CAAC,CAAC;IAEhF,OAAO,IAAA,gBAAU,EAAC,cAAc,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,eAAe,CAAC,aAAqB,EAAE,oBAA4B;IACvF,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;QAE3C,MAAM,eAAe,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC;QAClD,MAAM,sBAAsB,GAAG,IAAA,gBAAU,EAAC,oBAAoB,CAAC,CAAC;QAEhE,IAAI,eAAe,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YAClC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,IAAI,sBAAsB,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YACzC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,gDAAgD;QAChD,MAAM,aAAa,GAAG,IAAI,CAAC,CAAC,uCAAuC;QACnE,MAAM,YAAY,GAAG,IAAI,GAAG,EAAE,CAAC;QAE/B,2BAA2B;QAC3B,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;QACtD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,sBAAsB,EAAE,YAAY,CAAC,CAAC;QAE5D,6FAA6F;QAC7F,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAC7B,uBAAuB,EACvB,QAAQ,EACR,CAAC,QAAQ,EAAE,QAAQ,CAAC,EACpB,CAAC,YAAY,EAAE,aAAa,CAAC,CAC9B,CAAC;QAEF,OAAO,MAAM,KAAK,CAAC,CAAC;IACtB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,0DAA0D;QAC1D,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,IAAI,CAAC,UAAkB,EAAE,aAAqB,EAAE,YAAoB;IACxF,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;IAE3C,MAAM,YAAY,GAAG,IAAA,gBAAU,EAAC,UAAU,CAAC,CAAC;IAC5C,MAAM,eAAe,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC;IAClD,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;IAEhD,IAAI,eAAe,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,sDAAsD,eAAe,CAAC,MAAM,EAAE,CAAC,CAAC;IAClG,CAAC;IAED,IAAI,cAAc,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,qDAAqD,cAAc,CAAC,MAAM,EAAE,CAAC,CAAC;IAChG,CAAC;IAED,gFAAgF;IAChF,MAAM,UAAU,GAAG,MAAM,CAAC;IAC1B,MAAM,aAAa,GAAG,MAAM,CAAC;IAC7B,MAAM,YAAY,GAAG,MAAM,CAAC;IAC5B,MAAM,YAAY,GAAG,MAAM,CAAC;IAE5B,2BAA2B;IAC3B,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAChD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;IACtD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;IAEpD,iBAAiB;IACjB,UAAU,CAAC,KAAK,CACd,WAAW,EACX,MAAM,EACN,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAClD,CAAC,YAAY,EAAE,UAAU,EAAE,YAAY,CAAC,MAAM,EAAE,YAAY,EAAE,aAAa,CAAC,CAC7E,CAAC;IAEF,iBAAiB;IACjB,MAAM,cAAc,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IAC1C,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,GAAG,EAAE,CAAC,CAAC,CAAC;IAEhF,OAAO,IAAA,gBAAU,EAAC,cAAc,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,MAAM,CAAC,YAAoB,EAAE,UAAkB,EAAE,YAAoB;IACzF,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;QAE3C,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;QAChD,MAAM,YAAY,GAAG,IAAA,gBAAU,EAAC,UAAU,CAAC,CAAC;QAC5C,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;QAEhD,IAAI,cAAc,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YACjC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,IAAI,cAAc,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YACjC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,gFAAgF;QAChF,MAAM,UAAU,GAAG,MAAM,CAAC;QAC1B,MAAM,YAAY,GAAG,MAAM,CAAC;QAC5B,MAAM,YAAY,GAAG,MAAM,CAAC;QAE5B,2BAA2B;QAC3B,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QACpD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;QAChD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QAEpD,gCAAgC;QAChC,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAC7B,aAAa,EACb,QAAQ,EACR,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,CAAC,EACxC,CAAC,YAAY,EAAE,UAAU,EAAE,YAAY,CAAC,MAAM,EAAE,YAAY,CAAC,CAC9D,CAAC;QAEF,OAAO,MAAM,KAAK,CAAC,CAAC;IACtB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
|
||||
51
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts
vendored
Normal file
51
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import { DecodedPacket, PacketStructure } from '../types/packet';
|
||||
import { DecryptionOptions, ValidationResult, CryptoKeyStore } from '../types/crypto';
|
||||
export declare class MeshCorePacketDecoder {
|
||||
/**
|
||||
* Decode a raw packet from hex string
|
||||
*/
|
||||
static decode(hexData: string, options?: DecryptionOptions): DecodedPacket;
|
||||
/**
|
||||
* Decode a raw packet from hex string with signature verification for advertisements
|
||||
*/
|
||||
static decodeWithVerification(hexData: string, options?: DecryptionOptions): Promise<DecodedPacket>;
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown
|
||||
*/
|
||||
static analyzeStructure(hexData: string, options?: DecryptionOptions): PacketStructure;
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown with signature verification for advertisements
|
||||
*/
|
||||
static analyzeStructureWithVerification(hexData: string, options?: DecryptionOptions): Promise<PacketStructure>;
|
||||
/**
|
||||
* Internal unified parsing method
|
||||
*/
|
||||
private static parseInternal;
|
||||
/**
|
||||
* Internal unified parsing method with signature verification for advertisements
|
||||
*/
|
||||
private static parseInternalAsync;
|
||||
/**
|
||||
* Validate packet format without full decoding
|
||||
*/
|
||||
static validate(hexData: string): ValidationResult;
|
||||
/**
|
||||
* Calculate message hash for a packet
|
||||
*/
|
||||
static calculateMessageHash(bytes: Uint8Array, routeType: number, payloadType: number, payloadVersion: number): string;
|
||||
/**
|
||||
* Create a key store for decryption
|
||||
*/
|
||||
static createKeyStore(initialKeys?: {
|
||||
channelSecrets?: string[];
|
||||
nodeKeys?: Record<string, string>;
|
||||
}): CryptoKeyStore;
|
||||
/**
|
||||
* Decode a path_len byte into hash size, hop count, and total byte length.
|
||||
* Firmware reference: Packet.h lines 79-83
|
||||
* Bits 7:6 = hash size selector: (path_len >> 6) + 1 = 1, 2, or 3 bytes per hop
|
||||
* Bits 5:0 = hop count (0-63)
|
||||
*/
|
||||
private static decodePathLenByte;
|
||||
}
|
||||
//# sourceMappingURL=packet-decoder.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"packet-decoder.d.ts","sourceRoot":"","sources":["../../src/decoder/packet-decoder.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,eAAe,EAAkD,MAAM,iBAAiB,CAAC;AAIjH,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAatF,qBAAa,qBAAqB;IAChC;;OAEG;IACH,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa;IAK1E;;OAEG;WACU,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC;IAKzG;;OAEG;IACH,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,eAAe;IAKtF;;OAEG;WACU,gCAAgC,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC;IAKrH;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,aAAa;IA6Y5B;;OAEG;mBACkB,kBAAkB;IA2CvC;;OAEG;IACH,MAAM,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB;IAmDlD;;OAEG;IACH,MAAM,CAAC,oBAAoB,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM;IAkDtH;;OAEG;IACH,MAAM,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE;QAClC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACnC,GAAG,cAAc;IAIlB;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,iBAAiB;CAMjC"}
|
||||
576
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js
vendored
Normal file
576
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js
vendored
Normal file
@@ -0,0 +1,576 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MeshCorePacketDecoder = void 0;
|
||||
const enums_1 = require("../types/enums");
|
||||
const hex_1 = require("../utils/hex");
|
||||
const enum_names_1 = require("../utils/enum-names");
|
||||
const key_manager_1 = require("../crypto/key-manager");
|
||||
const advert_1 = require("./payload-decoders/advert");
|
||||
const trace_1 = require("./payload-decoders/trace");
|
||||
const group_text_1 = require("./payload-decoders/group-text");
|
||||
const request_1 = require("./payload-decoders/request");
|
||||
const response_1 = require("./payload-decoders/response");
|
||||
const anon_request_1 = require("./payload-decoders/anon-request");
|
||||
const ack_1 = require("./payload-decoders/ack");
|
||||
const path_1 = require("./payload-decoders/path");
|
||||
const text_message_1 = require("./payload-decoders/text-message");
|
||||
const control_1 = require("./payload-decoders/control");
|
||||
class MeshCorePacketDecoder {
|
||||
/**
|
||||
* Decode a raw packet from hex string
|
||||
*/
|
||||
static decode(hexData, options) {
|
||||
const result = this.parseInternal(hexData, false, options);
|
||||
return result.packet;
|
||||
}
|
||||
/**
|
||||
* Decode a raw packet from hex string with signature verification for advertisements
|
||||
*/
|
||||
static async decodeWithVerification(hexData, options) {
|
||||
const result = await this.parseInternalAsync(hexData, false, options);
|
||||
return result.packet;
|
||||
}
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown
|
||||
*/
|
||||
static analyzeStructure(hexData, options) {
|
||||
const result = this.parseInternal(hexData, true, options);
|
||||
return result.structure;
|
||||
}
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown with signature verification for advertisements
|
||||
*/
|
||||
static async analyzeStructureWithVerification(hexData, options) {
|
||||
const result = await this.parseInternalAsync(hexData, true, options);
|
||||
return result.structure;
|
||||
}
|
||||
/**
|
||||
* Internal unified parsing method
|
||||
*/
|
||||
static parseInternal(hexData, includeStructure, options) {
|
||||
const bytes = (0, hex_1.hexToBytes)(hexData);
|
||||
const segments = [];
|
||||
if (bytes.length < 2) {
|
||||
const errorPacket = {
|
||||
messageHash: '',
|
||||
routeType: enums_1.RouteType.Flood,
|
||||
payloadType: enums_1.PayloadType.RawCustom,
|
||||
payloadVersion: enums_1.PayloadVersion.Version1,
|
||||
pathLength: 0,
|
||||
path: null,
|
||||
payload: { raw: '', decoded: null },
|
||||
totalBytes: bytes.length,
|
||||
isValid: false,
|
||||
errors: ['Packet too short (minimum 2 bytes required)']
|
||||
};
|
||||
const errorStructure = {
|
||||
segments: [],
|
||||
totalBytes: bytes.length,
|
||||
rawHex: hexData.toUpperCase(),
|
||||
messageHash: '',
|
||||
payload: {
|
||||
segments: [],
|
||||
hex: '',
|
||||
startByte: 0,
|
||||
type: 'Unknown'
|
||||
}
|
||||
};
|
||||
return { packet: errorPacket, structure: errorStructure };
|
||||
}
|
||||
try {
|
||||
let offset = 0;
|
||||
// parse header
|
||||
const header = bytes[0];
|
||||
const routeType = header & 0x03;
|
||||
const payloadType = (header >> 2) & 0x0F;
|
||||
const payloadVersion = (header >> 6) & 0x03;
|
||||
if (includeStructure) {
|
||||
segments.push({
|
||||
name: 'Header',
|
||||
description: 'Header byte breakdown',
|
||||
startByte: 0,
|
||||
endByte: 0,
|
||||
value: `0x${header.toString(16).padStart(2, '0')}`,
|
||||
headerBreakdown: {
|
||||
fullBinary: header.toString(2).padStart(8, '0'),
|
||||
fields: [
|
||||
{
|
||||
bits: '0-1',
|
||||
field: 'Route Type',
|
||||
value: (0, enum_names_1.getRouteTypeName)(routeType),
|
||||
binary: (header & 0x03).toString(2).padStart(2, '0')
|
||||
},
|
||||
{
|
||||
bits: '2-5',
|
||||
field: 'Payload Type',
|
||||
value: (0, enum_names_1.getPayloadTypeName)(payloadType),
|
||||
binary: ((header >> 2) & 0x0F).toString(2).padStart(4, '0')
|
||||
},
|
||||
{
|
||||
bits: '6-7',
|
||||
field: 'Version',
|
||||
value: payloadVersion.toString(),
|
||||
binary: ((header >> 6) & 0x03).toString(2).padStart(2, '0')
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
offset = 1;
|
||||
// handle transport codes
|
||||
let transportCodes;
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
if (bytes.length < offset + 4) {
|
||||
throw new Error('Packet too short for transport codes');
|
||||
}
|
||||
const code1 = bytes[offset] | (bytes[offset + 1] << 8);
|
||||
const code2 = bytes[offset + 2] | (bytes[offset + 3] << 8);
|
||||
transportCodes = [code1, code2];
|
||||
if (includeStructure) {
|
||||
const transportCode = (bytes[offset]) | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24);
|
||||
segments.push({
|
||||
name: 'Transport Code',
|
||||
description: 'Used for Direct/Response routing',
|
||||
startByte: offset,
|
||||
endByte: offset + 3,
|
||||
value: `0x${transportCode.toString(16).padStart(8, '0')}`
|
||||
});
|
||||
}
|
||||
offset += 4;
|
||||
}
|
||||
// parse path length byte (encodes hash size and hop count)
|
||||
// Bits 7:6 = hash size selector: (path_len >> 6) + 1 = 1, 2, or 3 bytes per hop
|
||||
// Bits 5:0 = hop count (0-63)
|
||||
if (bytes.length < offset + 1) {
|
||||
throw new Error('Packet too short for path length');
|
||||
}
|
||||
const pathLenByte = bytes[offset];
|
||||
const { hashSize: pathHashSize, hopCount: pathHopCount, byteLength: pathByteLength } = this.decodePathLenByte(pathLenByte);
|
||||
if (pathHashSize === 4) {
|
||||
throw new Error('Invalid path length byte: reserved hash size (bits 7:6 = 11)');
|
||||
}
|
||||
if (includeStructure) {
|
||||
const hashDesc = pathHashSize > 1 ? ` × ${pathHashSize}-byte hashes (${pathByteLength} bytes)` : '';
|
||||
let pathLengthDescription;
|
||||
if (pathHopCount === 0) {
|
||||
pathLengthDescription = pathHashSize > 1 ? `No path data (${pathHashSize}-byte hash mode)` : 'No path data';
|
||||
}
|
||||
else if (routeType === enums_1.RouteType.Direct || routeType === enums_1.RouteType.TransportDirect) {
|
||||
pathLengthDescription = `${pathHopCount} hops${hashDesc} of routing instructions (decreases as packet travels)`;
|
||||
}
|
||||
else if (routeType === enums_1.RouteType.Flood || routeType === enums_1.RouteType.TransportFlood) {
|
||||
pathLengthDescription = `${pathHopCount} hops${hashDesc} showing route taken (increases as packet floods)`;
|
||||
}
|
||||
else {
|
||||
pathLengthDescription = `Path contains ${pathHopCount} hops${hashDesc}`;
|
||||
}
|
||||
segments.push({
|
||||
name: 'Path Length',
|
||||
description: pathLengthDescription,
|
||||
startByte: offset,
|
||||
endByte: offset,
|
||||
value: `0x${pathLenByte.toString(16).padStart(2, '0')}`,
|
||||
headerBreakdown: {
|
||||
fullBinary: pathLenByte.toString(2).padStart(8, '0'),
|
||||
fields: [
|
||||
{
|
||||
bits: '6-7',
|
||||
field: 'Hash Size',
|
||||
value: `${pathHashSize} byte${pathHashSize > 1 ? 's' : ''} per hop`,
|
||||
binary: ((pathLenByte >> 6) & 0x03).toString(2).padStart(2, '0')
|
||||
},
|
||||
{
|
||||
bits: '0-5',
|
||||
field: 'Hop Count',
|
||||
value: `${pathHopCount} hop${pathHopCount !== 1 ? 's' : ''}`,
|
||||
binary: (pathLenByte & 63).toString(2).padStart(6, '0')
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
if (bytes.length < offset + pathByteLength) {
|
||||
throw new Error('Packet too short for path data');
|
||||
}
|
||||
// convert path data to grouped hex strings (one entry per hop)
|
||||
const pathBytes = bytes.subarray(offset, offset + pathByteLength);
|
||||
let path = null;
|
||||
if (pathHopCount > 0) {
|
||||
path = [];
|
||||
for (let i = 0; i < pathHopCount; i++) {
|
||||
const hopBytes = pathBytes.subarray(i * pathHashSize, (i + 1) * pathHashSize);
|
||||
path.push((0, hex_1.bytesToHex)(hopBytes));
|
||||
}
|
||||
}
|
||||
if (includeStructure && pathHopCount > 0) {
|
||||
if (payloadType === enums_1.PayloadType.Trace) {
|
||||
// TRACE packets have SNR values in path (always single-byte entries)
|
||||
const snrValues = [];
|
||||
for (let i = 0; i < pathByteLength; i++) {
|
||||
const snrRaw = bytes[offset + i];
|
||||
const snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
|
||||
const snrDb = snrSigned / 4.0;
|
||||
snrValues.push(`${snrDb.toFixed(2)}dB (0x${snrRaw.toString(16).padStart(2, '0')})`);
|
||||
}
|
||||
segments.push({
|
||||
name: 'Path SNR Data',
|
||||
description: `SNR values collected during trace: ${snrValues.join(', ')}`,
|
||||
startByte: offset,
|
||||
endByte: offset + pathByteLength - 1,
|
||||
value: (0, hex_1.bytesToHex)(bytes.slice(offset, offset + pathByteLength))
|
||||
});
|
||||
}
|
||||
else {
|
||||
let pathDescription = 'Routing path information';
|
||||
if (routeType === enums_1.RouteType.Direct || routeType === enums_1.RouteType.TransportDirect) {
|
||||
pathDescription = `Routing instructions (${pathHashSize}-byte hashes stripped at each hop as packet travels to destination)`;
|
||||
}
|
||||
else if (routeType === enums_1.RouteType.Flood || routeType === enums_1.RouteType.TransportFlood) {
|
||||
pathDescription = `Historical route taken (${pathHashSize}-byte hashes added as packet floods through network)`;
|
||||
}
|
||||
segments.push({
|
||||
name: 'Path Data',
|
||||
description: pathDescription,
|
||||
startByte: offset,
|
||||
endByte: offset + pathByteLength - 1,
|
||||
value: (0, hex_1.bytesToHex)(bytes.slice(offset, offset + pathByteLength))
|
||||
});
|
||||
}
|
||||
}
|
||||
offset += pathByteLength;
|
||||
// extract payload
|
||||
const payloadBytes = bytes.subarray(offset);
|
||||
const payloadHex = (0, hex_1.bytesToHex)(payloadBytes);
|
||||
if (includeStructure && bytes.length > offset) {
|
||||
segments.push({
|
||||
name: 'Payload',
|
||||
description: `${(0, enum_names_1.getPayloadTypeName)(payloadType)} payload data`,
|
||||
startByte: offset,
|
||||
endByte: bytes.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(bytes.slice(offset))
|
||||
});
|
||||
}
|
||||
// decode payload based on type and optionally get segments in one pass
|
||||
let decodedPayload = null;
|
||||
const payloadSegments = [];
|
||||
if (payloadType === enums_1.PayloadType.Advert) {
|
||||
const result = advert_1.AdvertPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Trace) {
|
||||
const result = trace_1.TracePayloadDecoder.decode(payloadBytes, path, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0 // Payload segments are relative to payload start
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments; // Remove from decoded payload to keep it clean
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.GroupText) {
|
||||
const result = group_text_1.GroupTextPayloadDecoder.decode(payloadBytes, {
|
||||
...options,
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Request) {
|
||||
const result = request_1.RequestPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0 // Payload segments are relative to payload start
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Response) {
|
||||
const result = response_1.ResponsePayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0 // Payload segments are relative to payload start
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.AnonRequest) {
|
||||
const result = anon_request_1.AnonRequestPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Ack) {
|
||||
const result = ack_1.AckPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Path) {
|
||||
decodedPayload = path_1.PathPayloadDecoder.decode(payloadBytes);
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.TextMessage) {
|
||||
const result = text_message_1.TextMessagePayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Control) {
|
||||
const result = control_1.ControlPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
// if no segments were generated and we need structure, show basic payload info
|
||||
if (includeStructure && payloadSegments.length === 0 && bytes.length > offset) {
|
||||
payloadSegments.push({
|
||||
name: `${(0, enum_names_1.getPayloadTypeName)(payloadType)} Payload`,
|
||||
description: `Raw ${(0, enum_names_1.getPayloadTypeName)(payloadType)} payload data (${payloadBytes.length} bytes)`,
|
||||
startByte: 0,
|
||||
endByte: payloadBytes.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payloadBytes)
|
||||
});
|
||||
}
|
||||
// calculate message hash
|
||||
const messageHash = this.calculateMessageHash(bytes, routeType, payloadType, payloadVersion);
|
||||
const packet = {
|
||||
messageHash,
|
||||
routeType,
|
||||
payloadType,
|
||||
payloadVersion,
|
||||
transportCodes,
|
||||
pathLength: pathHopCount,
|
||||
...(pathHashSize > 1 ? { pathHashSize } : {}),
|
||||
path,
|
||||
payload: {
|
||||
raw: payloadHex,
|
||||
decoded: decodedPayload
|
||||
},
|
||||
totalBytes: bytes.length,
|
||||
isValid: true
|
||||
};
|
||||
const structure = {
|
||||
segments,
|
||||
totalBytes: bytes.length,
|
||||
rawHex: hexData.toUpperCase(),
|
||||
messageHash,
|
||||
payload: {
|
||||
segments: payloadSegments,
|
||||
hex: payloadHex,
|
||||
startByte: offset,
|
||||
type: (0, enum_names_1.getPayloadTypeName)(payloadType)
|
||||
}
|
||||
};
|
||||
return { packet, structure };
|
||||
}
|
||||
catch (error) {
|
||||
const errorPacket = {
|
||||
messageHash: '',
|
||||
routeType: enums_1.RouteType.Flood,
|
||||
payloadType: enums_1.PayloadType.RawCustom,
|
||||
payloadVersion: enums_1.PayloadVersion.Version1,
|
||||
pathLength: 0,
|
||||
path: null,
|
||||
payload: { raw: '', decoded: null },
|
||||
totalBytes: bytes.length,
|
||||
isValid: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown decoding error']
|
||||
};
|
||||
const errorStructure = {
|
||||
segments: [],
|
||||
totalBytes: bytes.length,
|
||||
rawHex: hexData.toUpperCase(),
|
||||
messageHash: '',
|
||||
payload: {
|
||||
segments: [],
|
||||
hex: '',
|
||||
startByte: 0,
|
||||
type: 'Unknown'
|
||||
}
|
||||
};
|
||||
return { packet: errorPacket, structure: errorStructure };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Internal unified parsing method with signature verification for advertisements
|
||||
*/
|
||||
static async parseInternalAsync(hexData, includeStructure, options) {
|
||||
// First do the regular parsing
|
||||
const result = this.parseInternal(hexData, includeStructure, options);
|
||||
// If it's an advertisement, verify the signature
|
||||
if (result.packet.payloadType === enums_1.PayloadType.Advert && result.packet.payload.decoded) {
|
||||
try {
|
||||
const advertPayload = result.packet.payload.decoded;
|
||||
const verifiedAdvert = await advert_1.AdvertPayloadDecoder.decodeWithVerification((0, hex_1.hexToBytes)(result.packet.payload.raw), {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
if (verifiedAdvert) {
|
||||
// Update the payload with signature verification results
|
||||
result.packet.payload.decoded = verifiedAdvert;
|
||||
// If the advertisement signature is invalid, mark the whole packet as invalid
|
||||
if (!verifiedAdvert.isValid) {
|
||||
result.packet.isValid = false;
|
||||
result.packet.errors = verifiedAdvert.errors || ['Invalid advertisement signature'];
|
||||
}
|
||||
// Update structure segments if needed
|
||||
if (includeStructure && verifiedAdvert.segments) {
|
||||
result.structure.payload.segments = verifiedAdvert.segments;
|
||||
delete verifiedAdvert.segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Signature verification failed:', error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Validate packet format without full decoding
|
||||
*/
|
||||
static validate(hexData) {
|
||||
const bytes = (0, hex_1.hexToBytes)(hexData);
|
||||
const errors = [];
|
||||
if (bytes.length < 2) {
|
||||
errors.push('Packet too short (minimum 2 bytes required)');
|
||||
return { isValid: false, errors };
|
||||
}
|
||||
try {
|
||||
let offset = 1; // Skip header
|
||||
// check transport codes
|
||||
const header = bytes[0];
|
||||
const routeType = header & 0x03;
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
if (bytes.length < offset + 4) {
|
||||
errors.push('Packet too short for transport codes');
|
||||
}
|
||||
offset += 4;
|
||||
}
|
||||
// check path length
|
||||
if (bytes.length < offset + 1) {
|
||||
errors.push('Packet too short for path length');
|
||||
}
|
||||
else {
|
||||
const pathLenByte = bytes[offset];
|
||||
const { hashSize, byteLength } = this.decodePathLenByte(pathLenByte);
|
||||
offset += 1;
|
||||
if (hashSize === 4) {
|
||||
errors.push('Invalid path length byte: reserved hash size (bits 7:6 = 11)');
|
||||
}
|
||||
if (bytes.length < offset + byteLength) {
|
||||
errors.push('Packet too short for path data');
|
||||
}
|
||||
offset += byteLength;
|
||||
}
|
||||
// check if we have payload data
|
||||
if (offset >= bytes.length) {
|
||||
errors.push('No payload data found');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : 'Validation error');
|
||||
}
|
||||
return { isValid: errors.length === 0, errors: errors.length > 0 ? errors : undefined };
|
||||
}
|
||||
/**
|
||||
* Calculate message hash for a packet
|
||||
*/
|
||||
static calculateMessageHash(bytes, routeType, payloadType, payloadVersion) {
|
||||
// for TRACE packets, use the trace tag as hash
|
||||
if (payloadType === enums_1.PayloadType.Trace && bytes.length >= 13) {
|
||||
let offset = 1;
|
||||
// skip transport codes if present
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
offset += 4;
|
||||
}
|
||||
// skip path data (decode path_len byte for multi-byte hops)
|
||||
if (bytes.length > offset) {
|
||||
const { byteLength } = this.decodePathLenByte(bytes[offset]);
|
||||
offset += 1 + byteLength;
|
||||
}
|
||||
// extract trace tag
|
||||
if (bytes.length >= offset + 4) {
|
||||
const traceTag = (bytes[offset]) | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24);
|
||||
return (0, hex_1.numberToHex)(traceTag, 8);
|
||||
}
|
||||
}
|
||||
// for other packets, create hash from constant parts
|
||||
const constantHeader = (payloadType << 2) | (payloadVersion << 6);
|
||||
let offset = 1;
|
||||
// skip transport codes if present
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
offset += 4;
|
||||
}
|
||||
// skip path data (decode path_len byte for multi-byte hops)
|
||||
if (bytes.length > offset) {
|
||||
const { byteLength } = this.decodePathLenByte(bytes[offset]);
|
||||
offset += 1 + byteLength;
|
||||
}
|
||||
const payloadData = bytes.slice(offset);
|
||||
const hashInput = [constantHeader, ...Array.from(payloadData)];
|
||||
// generate hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < hashInput.length; i++) {
|
||||
hash = ((hash << 5) - hash + hashInput[i]) & 0xffffffff;
|
||||
}
|
||||
return (0, hex_1.numberToHex)(hash, 8);
|
||||
}
|
||||
/**
|
||||
* Create a key store for decryption
|
||||
*/
|
||||
static createKeyStore(initialKeys) {
|
||||
return new key_manager_1.MeshCoreKeyStore(initialKeys);
|
||||
}
|
||||
/**
|
||||
* Decode a path_len byte into hash size, hop count, and total byte length.
|
||||
* Firmware reference: Packet.h lines 79-83
|
||||
* Bits 7:6 = hash size selector: (path_len >> 6) + 1 = 1, 2, or 3 bytes per hop
|
||||
* Bits 5:0 = hop count (0-63)
|
||||
*/
|
||||
static decodePathLenByte(pathLenByte) {
|
||||
const hashSize = (pathLenByte >> 6) + 1;
|
||||
const hopCount = pathLenByte & 63;
|
||||
return { hashSize, hopCount, byteLength: hopCount * hashSize };
|
||||
}
|
||||
}
|
||||
exports.MeshCorePacketDecoder = MeshCorePacketDecoder;
|
||||
//# sourceMappingURL=packet-decoder.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
11
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts
vendored
Normal file
11
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AckPayload } from '../../types/payloads';
|
||||
import { PayloadSegment } from '../../types/packet';
|
||||
export declare class AckPayloadDecoder {
|
||||
static decode(payload: Uint8Array, options?: {
|
||||
includeSegments?: boolean;
|
||||
segmentOffset?: number;
|
||||
}): AckPayload & {
|
||||
segments?: PayloadSegment[];
|
||||
} | null;
|
||||
}
|
||||
//# sourceMappingURL=ack.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ack.d.ts","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/ack.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAIpD,qBAAa,iBAAiB;IAC5B,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,GAAG;QAAE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;KAAE,GAAG,IAAI;CA2EzJ"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user