462 Commits

Author SHA1 Message Date
Jack Kingsman
5a9489eff1 Updating changelog + build for 2.7.8 2026-03-08 20:47:09 -07:00
Jack Kingsman
beb28b1f31 Updating changelog + build for 2.7.8 2026-03-08 20:42:03 -07:00
Jack Kingsman
7d688fa5f8 Move to more stable docker reqs without disrupting windows users 2026-03-08 20:38:33 -07:00
Jack Kingsman
09b68c37ba Better ci scripts 2026-03-08 19:56:58 -07:00
Jack Kingsman
df7dbad73d Fix bad file refs in decoder that break npm 10 2026-03-08 19:56:44 -07:00
Jack Kingsman
060fb1ef59 Updating changelog + build for 2.7.1 2026-03-08 18:48:14 -07:00
Jack Kingsman
b14e99ff24 Patch a bizarre browser quirk of leaky elements (???) in the packet list 2026-03-08 18:45:07 -07:00
Jack Kingsman
77523c1b15 Patch up to use a published patched meshcore-decoder and add a test script for different node versions 2026-03-08 18:35:58 -07:00
Jack Kingsman
9673b25ab3 yeeeikes fix raw packet feed sorry 2026-03-08 17:38:20 -07:00
Jack Kingsman
2732506f3c Fix historical DM packet length passing and fix up some docs 2026-03-08 17:12:36 -07:00
Jack Kingsman
523fe3e28e Updating changelog + build for 2.7.0 2026-03-08 16:23:23 -07:00
Jack Kingsman
3663db6ed3 Multibyte path support 2026-03-08 14:53:14 -07:00
Jack Kingsman
5832fbd2c9 Fix the meshcore decoder override 2026-03-08 14:52:26 -07:00
Jack Kingsman
655066ed73 Fix playwright tests for new radio status indicator 2026-03-08 14:30:47 -07:00
Jack Kingsman
5cb5c2ad25 Show hop width in the contact info modal 2026-03-08 13:54:07 -07:00
Jack Kingsman
69a6922827 Fix path modal support for multibyte 2026-03-08 13:54:07 -07:00
Jack Kingsman
806252ec7e Move to new meshcore_py version and rip out monkeypatch 2026-03-08 13:54:07 -07:00
Jack Kingsman
2236132df4 Add note bout brittle meshcore_py on corrupted advert handling 2026-03-08 13:54:07 -07:00
Jack Kingsman
564cd65496 Update tests with new out_path_hash_mode field and surface error on path hash mode set failure 2026-03-08 13:54:07 -07:00
Jack Kingsman
b3625b4937 Add statistics 2026-03-08 13:54:07 -07:00
Jack Kingsman
20af50585b Docs & tests 2026-03-08 13:54:07 -07:00
Jack Kingsman
d776f3d09b Add full-decrypt tests for multibyte paths 2026-03-08 13:54:07 -07:00
Jack Kingsman
075debc51b Force validation of path_hash_mode 2026-03-08 13:54:07 -07:00
Jack Kingsman
34318e4814 Use more faithful packet frame parsing 2026-03-08 13:54:07 -07:00
Jack Kingsman
48dab293ae Advert-path uses correct identity for dedupe 2026-03-08 13:54:07 -07:00
Jack Kingsman
76d11b01a7 Actually persist out_path_hash_mode instead of lossily deriving it 2026-03-08 13:54:06 -07:00
Jack Kingsman
69c812cfd4 Ewwww monkeypatch library bug I'm so sorry code gods. Bug reported at https://github.com/meshcore-dev/meshcore_py/issues/65 2026-03-08 13:54:06 -07:00
Jack Kingsman
2257c091e8 Fix visualizer coercion for multibyte hops 2026-03-08 13:54:06 -07:00
Jack Kingsman
55fb2390de Phase 8: Tests & Misc 2026-03-08 13:54:06 -07:00
Jack Kingsman
0b91fb18bd Phase 7: Integration & Migration 2026-03-08 13:54:06 -07:00
Jack Kingsman
8948f2e504 Phase 6: Radio config + path hash mode 2026-03-08 13:54:06 -07:00
Jack Kingsman
5c413bf949 Phase 5: Frontend path rendering 2026-03-08 13:54:06 -07:00
Jack Kingsman
b0ffa28e46 Phase 4: Update advert path storage 2026-03-08 13:54:06 -07:00
Jack Kingsman
f97c846378 Phase 3: Add path size inference and also bin some stupid migration tests while we're at it 2026-03-08 13:54:06 -07:00
Jack Kingsman
e0d7c8a083 Whoops, pin newer meshcore lib 2026-03-08 13:54:06 -07:00
Jack Kingsman
11ce2be5fa Phase 2: Patch up message path metadata 2026-03-08 13:54:06 -07:00
Jack Kingsman
1fc041538e Phase 0.5 & 1: Centralize path utils, multi-hop packet decoding, updated PacketInfo shape 2026-03-08 13:54:06 -07:00
Jack Kingsman
0ac8e97ea2 Put tools in a collapsible 2026-03-08 13:54:01 -07:00
Jack Kingsman
e6743d2098 Updating changelog + build for 2.6.1 2026-03-08 12:40:28 -07:00
Jack Kingsman
f472ff7cab Fix multibyte meshcore-decoder dep hell 2026-03-08 12:34:47 -07:00
Jack Kingsman
7ac220aee1 Add git 2026-03-08 12:10:04 -07:00
Jack Kingsman
43e38ecc5b Updating changelog + build for 2.6.0 2026-03-08 00:08:42 -08:00
Jack Kingsman
99eddfc2ef Update status bar and boot up more quickly with actual radio status 2026-03-07 23:47:47 -08:00
Jack Kingsman
f9eb6ebd98 Fix stale ref issue in strict mode 2026-03-07 21:40:34 -08:00
Jack Kingsman
8f59606867 Fix library name 2026-03-06 22:22:36 -08:00
Jack Kingsman
d214da41c6 Change names of community MQTT 2026-03-06 22:14:11 -08:00
Jack Kingsman
da22eb5c48 Fanout integration UX overhaul 2026-03-06 21:37:11 -08:00
Jack Kingsman
94546f90a4 Back to main fanout screen on save 2026-03-06 18:20:53 -08:00
Jack Kingsman
f82cadb4e1 Show webhooks/apprise summary 2026-03-06 18:19:14 -08:00
Jack Kingsman
f60c656566 Add better coverage for alternative community MQTTs. Closes #39 2026-03-06 18:14:28 -08:00
Jack Kingsman
b5e2a4c269 Soften the 'always run tests' 2026-03-06 16:26:19 -08:00
Jack Kingsman
d4f73d318a Fanout modules!
Overhaul post-message fanout. Closes PR #37.
2026-03-06 16:08:23 -08:00
Jack Kingsman
8ffae50b87 Add some brutal tests for webhooks 2026-03-06 16:06:13 -08:00
Jack Kingsman
dd13768a44 Tighten up message broadcast contract 2026-03-06 15:55:04 -08:00
Jack Kingsman
3330028d27 Elevate error logging for message poll loop issues 2026-03-06 15:43:29 -08:00
Jack Kingsman
3144910cd9 Fix regression around direct path DMs 2026-03-06 15:41:47 -08:00
Jack Kingsman
819470cb40 Add missing httpx dep 2026-03-06 15:39:11 -08:00
Jack Kingsman
d7d06ec1f8 Remove some dead code and unify param names around not sending for actual real life messages vs. historical decrypt 2026-03-06 14:44:48 -08:00
Jack Kingsman
9d03844371 Improve module lifecycling 2026-03-06 14:44:48 -08:00
Jack Kingsman
929a931ce9 Add channel name to broadcasts 2026-03-06 14:44:48 -08:00
Jack Kingsman
cba9835568 Rework more coverage in e2e tests and don't force radio restart + better startup error handling 2026-03-06 14:44:48 -08:00
Jack Kingsman
58daf63d00 Change fanout tab name. 2026-03-06 14:44:48 -08:00
Jack Kingsman
bb13d223ca Remove unused IATA regex and reduncant community enabled check that's always true 2026-03-06 14:44:48 -08:00
Jack Kingsman
863251d670 Remove dead on_raw method and move json import somewhere not dumb 2026-03-06 14:44:48 -08:00
Jack Kingsman
5e042b7bcc Add health refresh to delete handler and correct concurrency description 2026-03-06 14:44:48 -08:00
Jack Kingsman
4d15c7d894 Add per-config id lock to reload and remove stale comment 2026-03-06 14:44:48 -08:00
Jack Kingsman
22e28a9e5b Add min length to name, 400 on unknown scope, normalize IATA 2026-03-06 14:44:48 -08:00
Jack Kingsman
e72c3abd7f Correct sender name and use non-deprecated loop getter 2026-03-06 14:44:48 -08:00
Jack Kingsman
439face70b Tweak fanout display and update docs 2026-03-06 14:44:48 -08:00
Jack Kingsman
55ac9df681 Parallelize fanouts and add module watchdog 2026-03-06 14:44:48 -08:00
Jack Kingsman
5808504ee0 Flesh out the fanout agents a bit more 2026-03-06 14:44:48 -08:00
Jack Kingsman
cb4333df4f Fanout hitlist fixes: bugs, quality, tests, webhook HMAC signing 2026-03-06 14:44:48 -08:00
Jack Kingsman
7534f0cc54 Patch smome doc issues and do minor bug mop up (outgoing bot message flaggin, unprotected bot endpoint, contact filtering on scope selection, don't drop disabled but configured community endpoints 2026-03-06 14:44:48 -08:00
Jack Kingsman
adfb4addb7 Add MQTT removal migration and fix tests + docs 2026-03-06 14:44:48 -08:00
Jack Kingsman
e99fed2e76 Add some test coverage 2026-03-06 14:44:47 -08:00
Jack Kingsman
13fa94acaa Richer saving options + more popping color on disabled integration 2026-03-06 14:44:47 -08:00
Jack Kingsman
418955198f Add Apprise 2026-03-06 14:44:47 -08:00
Jack Kingsman
e3e4e0b839 Add webhooks & reformat a bit 2026-03-06 14:44:47 -08:00
Jack Kingsman
5ecb63fde9 Move bots into Fanout & Forwarding 2026-03-06 14:44:47 -08:00
Jack Kingsman
7cd54d14d8 Move to modular fanout bus 2026-03-06 14:41:51 -08:00
Jack Kingsman
93b5bd908a Extend tests and fix docs 2026-03-06 14:41:34 -08:00
Jack Kingsman
de30dfe87b Backfill channel sender identity when it's available 2026-03-06 14:36:33 -08:00
Jack Kingsman
d5a60d6ca3 Attach contact name to DMs for blocking purposes 2026-03-06 14:28:41 -08:00
Jack Kingsman
8f2d55277f Add clearer messaging around back to chat 2026-03-06 11:40:29 -08:00
Jack Kingsman
cdf5c0b81e Some new theme movement 2026-03-06 09:17:22 -08:00
Jack Kingsman
ae51755f07 Fix issue with backend restart letting unreads accumulate on open thread 2026-03-05 23:01:04 -08:00
Jack Kingsman
7715732e69 Add sender_key to outgoing and make unread counts respect block list 2026-03-05 10:43:16 -08:00
Jack Kingsman
01a5dc8d93 A11y bug bash 2026-03-05 10:24:22 -08:00
Jack Kingsman
c7bd4dd3fc Updating changelog + build for 2.5.0 2026-03-05 00:13:15 -08:00
Jack Kingsman
a069af8364 Doc updates 2026-03-04 21:18:07 -08:00
Jack Kingsman
03f4963966 Guard flood scope and be better about blocking 2026-03-04 20:15:44 -08:00
Jack Kingsman
e439bc913a Reorganize the settings panes a bit 2026-03-04 19:46:20 -08:00
Jack Kingsman
d5fe9c677f Add contact blocking 2026-03-04 18:54:21 -08:00
Jack Kingsman
145609faf9 Add outgoing message region tagging. Closes #35. 2026-03-04 15:42:21 -08:00
Jack Kingsman
c2931a266e Add hop display map in pathing modal 2026-03-04 13:03:43 -08:00
Jack Kingsman
9629a35fe1 Don't exclude 7 day stale nodes on Map view when linked in 2026-03-04 12:28:37 -08:00
Jack Kingsman
1ce72ecdf7 Add #remoteterm as default channel 2026-03-04 10:56:31 -08:00
Jack Kingsman
e0fb093612 Fix non-cache refresh on unfocused active threads; doc and test improvements 2026-03-04 10:16:17 -08:00
Jack Kingsman
1f37da8d2d s/router/repeater/ 2026-03-03 22:22:08 -08:00
Jack Kingsman
a9472870f3 Add 'Show Key' prompt on private rooms to prevent key leakage. Closes #36 2026-03-03 21:39:03 -08:00
Jack Kingsman
d6611e8518 Add better logic around node-last-heard timing 2026-03-03 21:24:50 -08:00
Jack Kingsman
6274df7244 Add some color themes 2026-03-03 21:08:19 -08:00
Jack Kingsman
813a47ee14 Shard up frontend variables 2026-03-03 20:07:45 -08:00
Jack Kingsman
e0e71180b2 Add global message search and more e2e tests 2026-03-03 19:19:24 -08:00
Jack Kingsman
73a835688d Add channel info box 2026-03-03 17:09:48 -08:00
Jack Kingsman
5d2aaa802b Move controls to top 2026-03-03 16:19:33 -08:00
Jack Kingsman
eb78285b8f Add bot disable flow 2026-03-03 15:57:37 -08:00
Jack Kingsman
e8538c55ea Fix flaky test selector 2026-03-03 14:02:05 -08:00
Jack Kingsman
b1cb531911 Make node recency customizable in the visualizer 2026-03-03 13:52:55 -08:00
Jack Kingsman
8fa37fe6dc Websocket for contact deletion, radio contact deletion flag fix, resent message now appends sender name 2026-03-03 12:43:27 -08:00
Jack Kingsman
73d4647cfc Add node last heard reason to hover 2026-03-03 12:08:20 -08:00
Jack Kingsman
31afb7b9c0 Fix some doc gaps 2026-03-03 12:01:58 -08:00
Jack Kingsman
73f082c06c Fallback DM handler cleanliness (I never use this lol) 2026-03-03 11:59:15 -08:00
Jack Kingsman
ea49bdff35 ID tiebreaker for same-second messages 2026-03-03 11:57:53 -08:00
Jack Kingsman
21fd505fb9 Add sender name on outgoing messages 2026-03-03 11:56:18 -08:00
Jack Kingsman
62943f6292 Add why-active reason to visualizer nodes 2026-03-03 11:54:54 -08:00
Jack Kingsman
c76b7895dd Clarify MQTT limitation comment 2026-03-03 10:29:04 -08:00
Jack Kingsman
285c90f71e Conform status colors 2026-03-03 10:24:25 -08:00
Jack Kingsman
e4662229b4 Shorten client name 2026-03-03 09:40:20 -08:00
Jack Kingsman
be21b434cf Get closer to parity with meshcore packet capture 2026-03-03 09:31:24 -08:00
Jack Kingsman
662e84adbe Add community mqtt stats reporting 2026-03-03 09:17:57 -08:00
Jack Kingsman
5a72adc75b Add some QOL features to readiness scripts 2026-03-03 09:17:45 -08:00
Jack Kingsman
707f98d203 Fix packet sidebar retention 2026-03-03 09:05:21 -08:00
Jack Kingsman
4c1d5fb8ec Add status/LWT to community MQTT ingest 2026-03-02 23:10:25 -08:00
Jack Kingsman
fb279ccf1a Accessibility overhaul 2026-03-02 20:34:06 -08:00
Jack Kingsman
7d39e726b4 Local serving optimizations and Windows docs updates 2026-03-02 20:19:49 -08:00
Jack Kingsman
d9aa67d254 No more package locks 2026-03-02 19:25:40 -08:00
Jack Kingsman
81694e7ab3 Fix an e2e test and add a warning about nodes not showing up on community listings 2026-03-02 19:15:28 -08:00
Jack Kingsman
99f31c8226 Carve out dead code and cruft, and unify repeater status pane 2026-03-02 18:56:18 -08:00
Jack Kingsman
f715d72467 Doc and typo hunting 2026-03-02 18:05:45 -08:00
Jack Kingsman
f335fc56cc Patch up some missing tests and fix+test channel add not clearing on channel submission without add-another checked 2026-03-02 18:02:53 -08:00
Jack Kingsman
d8294a8383 Add more warnings around radio config, stats loading, and packet decrypt (and remove accidentally committed script whoops) 2026-03-02 16:46:18 -08:00
Jack Kingsman
79db09bd15 Don't show mark all as read if there's nothing to read 2026-03-02 16:42:59 -08:00
Jack Kingsman
e3fe36dc19 Fix stale closure on existing keys 2026-03-02 16:38:44 -08:00
Jack Kingsman
69584051f5 Don't message fetch on map or visualizer 2026-03-02 16:35:26 -08:00
Jack Kingsman
58ea1d7eb9 Be more protective around stripping at null byte, not after 2026-03-02 16:34:13 -08:00
Jack Kingsman
c9776639a0 Commit package-lock 2026-03-02 16:33:08 -08:00
Jack Kingsman
d860ea706d Add log line to show if our polling loop actually mops anything up 2026-03-02 15:32:58 -08:00
Jack Kingsman
b7976206fc Add some additional documentation notes 2026-03-02 15:28:32 -08:00
Jack Kingsman
f73d10328b Add note about let's mesh 2026-03-02 15:05:17 -08:00
Jack Kingsman
a8ff2b4133 Updating changelog + build for 2.4.0 2026-03-02 14:54:21 -08:00
Jack Kingsman
09ad642d79 Merge pull request #34 from jkingsman/community-mqtt
Community MQTT (LetsMesh). Closes #33.
2026-03-02 14:42:04 -08:00
Jack Kingsman
9e68544fe9 Add warning to MQTT section 2026-03-02 14:41:49 -08:00
Jack Kingsman
f059756064 Clearer labelling and page organization for MQTT 2026-03-02 14:25:44 -08:00
Jack Kingsman
95bacc4caf Split up community broker fields and reformat MQTT config page 2026-03-02 14:24:20 -08:00
Jack Kingsman
2581cc6af7 Show error toast on PK export failure 2026-03-02 14:24:20 -08:00
Jack Kingsman
05df314619 Refactor to combined base for MQTT 2026-03-02 14:24:19 -08:00
Jack Kingsman
00ca4afa8d Add support for community MQTT ingest 2026-03-02 14:24:19 -08:00
Jack Kingsman
2496d70c4b Add kofi link 2026-03-02 11:46:09 -08:00
Jack Kingsman
4b05dc2f41 Add clearer MQTT topics and payload shapes 2026-03-02 11:41:25 -08:00
Jack Kingsman
b8cdae8a03 Tag mesh-traffic-reliant tests with a warning 2026-03-02 10:52:48 -08:00
Jack Kingsman
3bad3cb21c Add clearer message for e2e test lags 2026-03-02 10:46:48 -08:00
Jack Kingsman
f118d5e222 Add debug log level info 2026-03-02 10:38:40 -08:00
Jack Kingsman
e0d87c4df3 Add licenses explicitly (probably should have been doing this for a while; oops! Apologies!) 2026-03-01 22:12:42 -08:00
Jack Kingsman
ed83d1b2c4 Add a sign of life to e2e tests 2026-03-01 19:23:56 -08:00
Jack Kingsman
d988309a2f Move settings ordering around 2026-03-01 19:11:21 -08:00
Jack Kingsman
7c37133856 Add some info and attribution 2026-03-01 18:53:14 -08:00
Jack Kingsman
0bde67d66c Move build scripts into better places 2026-03-01 18:06:55 -08:00
Jack Kingsman
56d4fa707a Updating changelog + build for 2.3.0 2026-03-01 17:37:21 -08:00
Jack Kingsman
a8af9b10f3 Break up repeater and settings into consituent files 2026-03-01 17:34:00 -08:00
Jack Kingsman
18ac86b4c0 Have visualizer remember settings 2026-03-01 16:38:25 -08:00
Jack Kingsman
e504f4de33 Test and doc improvements 2026-03-01 14:53:18 -08:00
Jack Kingsman
9c4b049c8d Fix double message render glitch 2026-03-01 14:37:40 -08:00
Jack Kingsman
330c5efb31 Merge pull request #29 from jkingsman/mqtt
MQTT support
2026-03-01 11:11:46 -08:00
Jack Kingsman
f993110ec4 Initial mqtt implementation 2026-03-01 11:11:36 -08:00
Jack Kingsman
c891a23a41 Drop py3.12 req 2026-02-28 23:16:57 -08:00
Jack Kingsman
1f1c0faccc DOn't double detch unreads 2026-02-28 21:28:35 -08:00
Jack Kingsman
727ac913de Add more efficient message pagination index to eliminate temporary b-tree indexing 2026-02-28 21:00:16 -08:00
Jack Kingsman
a55166989e Improve performance on unread endpoint 2026-02-28 20:10:38 -08:00
Jack Kingsman
a2b211a8bc Move prefetch to a better spot 2026-02-28 19:45:40 -08:00
Jack Kingsman
5d90727718 Remove advert info and add arrows between advert path nodes 2026-02-28 17:23:06 -08:00
Jack Kingsman
0ad17c8d1f Optimize build/lint perf 2026-02-28 16:21:52 -08:00
Jack Kingsman
365728be02 Make pathing clearable on click 2026-02-28 15:33:50 -08:00
Jack Kingsman
7cad4a98dd Show repeater path type/length in title bar to match contacts 2026-02-28 13:51:24 -08:00
Jack Kingsman
bac4db6b0a Updating changelog + build for 2.2.0 2026-02-28 13:33:49 -08:00
Jack Kingsman
60c0262490 Expand tests with E2E coverage 2026-02-28 13:24:13 -08:00
Jack Kingsman
ce99d63701 Reorganize for great victory and move to blob for payload hasg 2026-02-27 21:03:34 -08:00
Jack Kingsman
fc27361e37 Fix prefetch type glitch 2026-02-27 18:48:39 -08:00
Jack Kingsman
dcd473de6c Clear raw packet ref on reconnect 2026-02-27 17:45:17 -08:00
Jack Kingsman
57e6ba534a Improve prefetch safety 2026-02-27 17:14:29 -08:00
Jack Kingsman
17f6a2b8c5 Compact code bloat for message fire and channel sync loops 2026-02-27 17:03:18 -08:00
Jack Kingsman
884972f9e0 Add some tests and improve docs 2026-02-27 16:54:18 -08:00
Jack Kingsman
60455cdd7b Autoreconcile and don't bother with toast 2026-02-27 16:38:08 -08:00
Jack Kingsman
194852ed16 Move to blob storage for payload hashes 2026-02-27 15:46:16 -08:00
Jack Kingsman
6a3510ce2e Misc. doc, test, and qol improvements 2026-02-27 15:17:29 -08:00
Jack Kingsman
c40603a36f Cancellation guard on contact info pane 2026-02-27 15:05:22 -08:00
Jack Kingsman
2e8a4fde0a Historical DM decrypts are always incoming 2026-02-27 15:02:56 -08:00
Jack Kingsman
171b4405e5 Move contact sync to pass-the-mc mode 2026-02-27 14:59:52 -08:00
Jack Kingsman
c5fd0292b8 Repeater Overhaul 2026-02-27 14:38:17 -08:00
Jack Kingsman
66cbf98b74 Post-merge cleanup, AGENTS.md work, unused endpoints, etc. 2026-02-27 14:36:43 -08:00
Jack Kingsman
b3606169fe Add patchup commit 2026-02-27 14:28:17 -08:00
Jack Kingsman
d4a2b9fac8 Linting 2026-02-27 14:20:53 -08:00
Jack Kingsman
26fbfcd015 Repeater UI overhaul 2026-02-27 14:20:52 -08:00
Jack Kingsman
f4a383082e Merge pull request #28 from jkingsman/contact_info
Contact info pane
2026-02-27 13:47:57 -08:00
Jack Kingsman
b91b2d5d7b Contact info pane 2026-02-27 13:45:42 -08:00
Jack Kingsman
24166e92e8 Add continue-on-failure attempts for when contact loading fails. Might help remedy #27, but there's still an issue (maybe radio lag?) 2026-02-26 00:43:32 -08:00
Jack Kingsman
f003bda7b2 Don't queue packets while the page is hidden 2026-02-25 17:32:35 -08:00
Jack Kingsman
a406e00229 Add local label 2026-02-25 16:18:33 -08:00
Jack Kingsman
56f8b796e6 Add scroll to repeater infobox on visualizer 2026-02-25 16:03:47 -08:00
Jack Kingsman
6ec2350b9a Add scroll to repeater infobox on visualizer 2026-02-25 15:54:12 -08:00
Jack Kingsman
566181faed More e2e tests 2026-02-24 22:33:28 -08:00
Jack Kingsman
27942975e2 Don't short circuit on zero key because claude is useless tonight 2026-02-24 21:50:52 -08:00
Jack Kingsman
1c2fb148bc Misc cruft -- filtering, pagination tests, etc. 2026-02-24 21:15:49 -08:00
Jack Kingsman
684724913f Clear channel name on new channel tab swap 2026-02-24 20:56:27 -08:00
Jack Kingsman
0826030f1c Add errata about 200 return for deletion of nonexistent channel 2026-02-24 20:49:22 -08:00
Jack Kingsman
fb11690585 Add errata note about continuous retry 2026-02-24 20:47:59 -08:00
Jack Kingsman
5dcb52914b Catch failed vacuum 2026-02-24 20:47:10 -08:00
Jack Kingsman
b4a0b1c515 Add refresh prompt after WS loss 2026-02-24 20:45:47 -08:00
Jack Kingsman
81c166bb8d Do some utterly disgusting MC library munging to deal with contacts coming out of sync 2026-02-24 20:41:21 -08:00
Jack Kingsman
71359e437f Clarify errata and known limitations 2026-02-24 20:37:53 -08:00
Jack Kingsman
932ea6b65d Pause autofetch during poll loop 2026-02-24 20:30:39 -08:00
Jack Kingsman
2757f25eb9 Use radio lock after setup 2026-02-24 20:26:18 -08:00
Jack Kingsman
561c8cf9c0 More code cleanup and optimization 2026-02-24 19:59:46 -08:00
Jack Kingsman
1b76211d53 More code rip out 2026-02-24 19:11:51 -08:00
Jack Kingsman
b1a0456a05 Carve out some dead code 2026-02-24 18:40:35 -08:00
Jack Kingsman
f7f696bf10 Remove rerender thrashing on setConnected 2026-02-24 18:13:31 -08:00
Jack Kingsman
5c0f3df806 Track advert path and use in mesh visualizer
Track advert path and use in mesh visualizer
2026-02-24 15:00:50 -08:00
Jack Kingsman
c30ed0b4bc Track advert path and use in mesh visualizer 2026-02-24 14:55:28 -08:00
Jack Kingsman
440ab14d7f Rephrase command channel failure warning 2026-02-24 09:29:51 -08:00
Jack Kingsman
c25b21469e Add frontend fallback resolver 2026-02-24 00:18:11 -08:00
Jack Kingsman
17e526697f Add radio event-response-failure message into the logs 2026-02-24 00:10:48 -08:00
Jack Kingsman
27cd3bd710 Updating changelog + build for 2.1.0 2026-02-23 23:51:49 -08:00
Jack Kingsman
c0f740d5f9 API version reads from pyproject.toml 2026-02-23 23:38:01 -08:00
Jack Kingsman
cc6e788021 Fix typos 2026-02-23 23:34:49 -08:00
Jack Kingsman
033af4027d Update AGENTS.md and add tests for broadcast payload shape 2026-02-23 23:20:35 -08:00
Jack Kingsman
cc12128041 Add clearer error handling 2026-02-23 23:16:11 -08:00
Jack Kingsman
4f3d8a7838 Fix stuck post-connect failure state 2026-02-23 23:12:53 -08:00
Jack Kingsman
559935e3d5 Improve some coverage in integration form 2026-02-23 22:38:29 -08:00
Jack Kingsman
ecb748b9e3 Drop out crappy tests, and improve quality overall 2026-02-23 22:28:09 -08:00
Jack Kingsman
31bb1e7d22 Move glyph further down for centering 2026-02-23 21:58:47 -08:00
Jack Kingsman
72b66214fa Add tests for MC object handling 2026-02-23 21:52:29 -08:00
Jack Kingsman
2125653978 Correct yet MORE instances of not using a well sourced MC object 2026-02-23 21:46:57 -08:00
Jack Kingsman
31302b4972 Strip out f-string usages in queries. Don't set bad examples! 2026-02-23 21:07:05 -08:00
Jack Kingsman
c6a8c3835c Add note for other bug-finder LLMs about local message ID sub-millisecond collisions 2026-02-23 21:04:29 -08:00
Jack Kingsman
4b84f609b7 Fix content type and offset detection 2026-02-23 21:01:48 -08:00
Jack Kingsman
a22224980e Reduce WS churn for incoming duplicates that don't affect ack/path list 2026-02-23 20:55:32 -08:00
Jack Kingsman
ced0791c05 Add notes about known edge cases to prevent agent repop 2026-02-23 20:45:24 -08:00
Jack Kingsman
47867c50b8 Fix TOCTOU around radio reconnect 2026-02-23 20:42:11 -08:00
Jack Kingsman
1a4f57a03e Fix airtime polling cross-message display 2026-02-23 20:34:14 -08:00
Jack Kingsman
5d7a313c53 Add missing tests and address AGENTS.md gaps 2026-02-23 20:26:57 -08:00
Jack Kingsman
b9de3b7dd7 Reduce default poll time and add DM ack clearing to standard poll 2026-02-23 20:00:42 -08:00
Jack Kingsman
7306627ac7 Move to SSoT for message dedup to prevent phantom unreads 2026-02-23 19:52:42 -08:00
Jack Kingsman
1bd31d68d9 Update server-side keystore after key refresh 2026-02-23 19:33:17 -08:00
Jack Kingsman
152eab99db More stable MC object reference and proper radio disconnection detection 2026-02-23 19:11:58 -08:00
Jack Kingsman
cba9e20698 Drain before autofetch, fix same-second collisions, and always mc.disconnect() on false/probe failure 2026-02-23 17:33:35 -08:00
Jack Kingsman
619973bdf0 Add prebuilt image to docker-compose 2026-02-23 16:42:41 -08:00
Jack Kingsman
ef4c79bc80 Move to hour-resolution adverts 2026-02-23 16:34:34 -08:00
Jack Kingsman
88d5a76081 Better behavior and message tracking around repeater contact on a busy mesh 2026-02-23 15:59:52 -08:00
jkingsman
9193d113fe Tighten up docker compose and docs 2026-02-22 14:02:11 -08:00
Jack Kingsman
fd0f901546 Merge pull request #20 from suymur/feature/add-docker-compose
Add Docker Compose support for simplified deployment
2026-02-22 13:43:24 -08:00
Jack Kingsman
40d27dd8d6 Merge branch 'main' into feature/add-docker-compose 2026-02-22 13:43:15 -08:00
Jack Kingsman
54706700ab Remove unused from readme 2026-02-22 13:42:09 -08:00
Jack Kingsman
00aa212049 Add notes about ownership glitches + using prebuilt 2026-02-22 12:45:39 -08:00
Jack Kingsman
7542cc1142 Update README for docker compose 2026-02-22 12:07:37 -08:00
Jack Kingsman
d525188cce Change back npm ci and use standard paths + ports 2026-02-22 11:59:58 -08:00
Jack Kingsman
d635914d4b Remove unnecessary and clashing rounded border on settings panes 2026-02-22 11:53:05 -08:00
Jack Kingsman
e806430a73 Merge pull request #21 from yellowcooln/main
Fix settings page scroll lock at browser zoom levels
2026-02-22 11:47:43 -08:00
Jack Kingsman
2e23733f41 Fix README docker image
Update README.md
2026-02-22 08:49:12 -08:00
Schappi
7e52982399 Update README.md
docker repository has changed...
2026-02-22 15:23:04 +01:00
Jack Kingsman
40dde4647a Correct button alignment 2026-02-21 17:23:46 -08:00
Jack Kingsman
7463f4e032 Move resend button into modal 2026-02-21 17:01:13 -08:00
Yellowcooln
a7b5dcc9d8 Adjust class names for SettingsModal layout 2026-02-21 18:01:19 -05:00
Jack Kingsman
1e53fe9515 Better warning phrasing 2026-02-21 09:30:03 -08:00
Jack Kingsman
1477900f6f Linting... 2026-02-21 00:14:49 -08:00
Jack Kingsman
11f07f3501 Add endpoint for deleting raw packets of decrypted messages 2026-02-21 00:11:57 -08:00
Jack Kingsman
6d0505ade6 WAL + incremental vacuum for space happiness 2026-02-21 00:04:27 -08:00
Jack Kingsman
9e3b1d03a9 Drop unnecessary uniqs and indices 2026-02-21 00:00:13 -08:00
Jack Kingsman
9352b272d5 Bug cleanup: legacy hash restoration + dupicated convo router checks 2026-02-20 22:58:34 -08:00
Jack Kingsman
c90a30787a Experimental dynamic manifest 2026-02-20 22:49:39 -08:00
Jack Kingsman
2321411ef0 Fix typo and change startup load hash behavior 2026-02-20 17:33:02 -08:00
Jack Kingsman
a8a8f6e08b Fix typo and disable autocomplete 2026-02-20 17:26:30 -08:00
Jack Kingsman
f9eb46f2ab Remember last used channel when selected 2026-02-20 17:16:05 -08:00
Jack Kingsman
41bf4eb73a Hide character counter for short messages on mobile 2026-02-20 17:15:57 -08:00
suymur
e0ca50afc8 Add Docker Compose support for simplified deployment
- Add docker-compose.yaml with service configuration
- Support for multiple transport options (TCP, Serial, BLE)
- Configure standard port mapping (8000:8000)
- Use named volume for portable data persistence
- Update Dockerfile to use npm install for better compatibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-20 11:23:16 +01:00
Jack Kingsman
d08a113fc8 Updating changelog + build for 2.0.1 2026-02-16 23:06:51 -08:00
Jack Kingsman
f490cc756f Fix bug with statistics display on mobile 2026-02-16 23:01:04 -08:00
Jack Kingsman
3232075767 Update README a smidge with new features 2026-02-16 22:36:30 -08:00
Jack Kingsman
a9d650ecd4 Update screenshot for 2.0 2026-02-16 22:27:53 -08:00
Jack Kingsman
7c23dcf6d9 Updating changelog + build for 2.0.0 2026-02-16 22:13:19 -08:00
Jack Kingsman
6e4872e25b Full screen mesh visualizer view 2026-02-16 22:08:25 -08:00
Jack Kingsman
ef2b22a865 Don't dedupe adverts on payload (since what we care about is the path) 2026-02-16 22:03:04 -08:00
Jack Kingsman
a4d8707479 Fix missing padding on collapsed visualizer 2026-02-16 21:02:47 -08:00
Jack Kingsman
0e25bd2281 Fix dedupe for frontend raw packet delivery 2026-02-16 20:46:43 -08:00
Jack Kingsman
56fde32970 Linting goodness 2026-02-16 19:20:11 -08:00
Jack Kingsman
1a59eb89fa Clarify some doc drift 2026-02-16 19:15:27 -08:00
Jack Kingsman
65b74b624b Add missing prefix-message claim in other contact sync spots we missed 2026-02-16 19:14:05 -08:00
Jack Kingsman
95e8bcca08 Clarify packet dedupe policy 2026-02-16 19:11:44 -08:00
Jack Kingsman
e8ddba0131 Add radio lock acquire around missing spots, and validate 2026-02-16 19:10:20 -08:00
Jack Kingsman
8ca48cd6bc Use actual pubkey matching for path update, not default, and don't action the serial path update events 2026-02-16 19:06:09 -08:00
Jack Kingsman
72f12d80e5 Fix repeater command timestamp field usage 2026-02-16 18:59:39 -08:00
Jack Kingsman
d2f5bd84a8 Make broadcast timestamp match fallback logic used for storage 2026-02-16 18:34:20 -08:00
Jack Kingsman
cbe091ad90 Add clarifying comment for missing DM pathing info 2026-02-16 18:32:45 -08:00
Jack Kingsman
1f853aa54e Fix out of order path WS broadcasts overwriting each other 2026-02-16 18:30:27 -08:00
Jack Kingsman
8457799a60 Use contact object on broadcast from DB rather than hand-rolling 2026-02-16 18:26:05 -08:00
Jack Kingsman
591d333970 More missed lowercase key spots 2026-02-16 18:24:25 -08:00
Jack Kingsman
54a03a9467 Add guard for conversation switch mid-message-fetch 2026-02-16 18:23:10 -08:00
Jack Kingsman
de7ab37998 Clear and reset only the visualizer, not the packet feed 2026-02-16 18:00:03 -08:00
Jack Kingsman
3042beaf27 Invert UI show/hide logic to be positive not negative 2026-02-16 17:53:41 -08:00
Jack Kingsman
d4a7b37fa9 Whoops, linting 2026-02-16 17:49:27 -08:00
Jack Kingsman
6a3a99fe9f Clarify option labelling on visualizer 2026-02-16 17:45:30 -08:00
Jack Kingsman
7d340f19e0 Move orbit option to checkbox 2026-02-16 17:42:19 -08:00
Jack Kingsman
95d806717b Default to heuristic repeater grouping 2026-02-16 17:40:19 -08:00
Jack Kingsman
7f426ece4e Add last-five-min visualizer filtering 2026-02-16 17:39:35 -08:00
Jack Kingsman
7bb0e5e719 Clarify meaning of ack window 2026-02-16 17:36:42 -08:00
Jack Kingsman
a495f284ea Remove visualizer shuffle; not needed in 3D (nearly always worse layout) 2026-02-16 17:36:05 -08:00
Jack Kingsman
7df21d03f2 Change visualizer layout 2026-02-16 17:33:20 -08:00
Jack Kingsman
be007322d2 Frontend overhaul 2026-02-16 17:28:21 -08:00
Jack Kingsman
58900f7649 Add logo glyph 2026-02-16 16:49:48 -08:00
Jack Kingsman
877649ddc7 Frontend color overhaul 2026-02-16 16:45:05 -08:00
Jack Kingsman
24685038f8 Updating changelog + build for 1.10.0 2026-02-16 16:26:22 -08:00
Jack Kingsman
1e73cbf266 Add orbit to 3D viewer 2026-02-16 16:22:39 -08:00
Jack Kingsman
241f94ceaf Expand node radios in 3D view 2026-02-16 16:17:39 -08:00
Jack Kingsman
e157826364 Actually clean up node labels on clear 2026-02-16 16:16:44 -08:00
Jack Kingsman
89d311e4ae Move visualizer to 3D 2026-02-16 15:36:44 -08:00
Jack Kingsman
0d03945b81 Fix turbo resend 2026-02-16 00:37:27 -08:00
Jack Kingsman
945053c20a Add turbo resend for testing 2026-02-15 23:49:50 -08:00
Jack Kingsman
1f3042f360 Add statistics endpoint 2026-02-15 12:54:42 -08:00
Jack Kingsman
3756579f9d Dedupe contacts/sidebar by key not name 2026-02-14 21:46:24 -08:00
Jack Kingsman
6e3cf28577 Fix ack/message race condition where out of sequence acks and messages would cause dropped acks 2026-02-14 21:19:25 -08:00
Jack Kingsman
9afaee24a0 Persist collapae state 2026-02-14 19:10:44 -08:00
Jack Kingsman
c91449260d Fix sidebar and test typing 2026-02-14 18:04:43 -08:00
Jack Kingsman
36098f62b8 Merge pull request #18 from rgregg/codex/sidebar-contacts-repeaters-sections
feat(sidebar): add collapsible sections and split repeaters list
2026-02-14 17:58:59 -08:00
Jack Kingsman
8bb408180e Add unread badge at section level 2026-02-14 17:58:35 -08:00
Jack Kingsman
b34bc1491a Unify row rendering 2026-02-14 17:50:58 -08:00
Ryan Gregg
4919f551f8 feat(sidebar): add collapsible sections and split repeaters list 2026-02-14 17:50:57 -08:00
Jack Kingsman
5a82d469b4 Add resend button for 30s 2026-02-14 17:37:51 -08:00
Jack Kingsman
7b2d5b817e Fix multi-send message pathing not appearing 2026-02-14 16:49:27 -08:00
Jack Kingsman
a598cbbd1a Clearer username styling 2026-02-13 01:29:36 -08:00
Jack Kingsman
76db547f50 Better contrast; happier eyeballs! 2026-02-13 01:26:24 -08:00
Jack Kingsman
1c4d6c07a8 Prefetch all the things! 2026-02-13 00:48:37 -08:00
Jack Kingsman
908a479fa6 Improve perf with reduced fetching, more chunking, and window-level prefetch 2026-02-13 00:43:07 -08:00
Jack Kingsman
b14ad71eca Action some lighthouse findings 2026-02-13 00:12:54 -08:00
Jack Kingsman
57d007dec2 Calm down sidebar refreshes with better contact don't-set behavior, unread count checks, and memoized sorting etc. 2026-02-13 00:00:53 -08:00
Jack Kingsman
430b5aaba7 Support incoming/outgoing detection in bots 2026-02-12 23:52:49 -08:00
Jack Kingsman
0fcf6a5653 s/stopped/idle/ on cracker interface 2026-02-12 19:53:25 -08:00
Jack Kingsman
3394183892 Fix outgoing first message top padding missing 2026-02-12 19:26:54 -08:00
Jack Kingsman
e796f44b38 Updating changelog + build for 1.9.3 2026-02-12 11:21:45 -08:00
Jack Kingsman
40a42ca7a1 Update cracker to support two-word mode 2026-02-12 11:15:43 -08:00
Jack Kingsman
f26b3d5fc7 Updating changelog + build for 1.9.2 2026-02-12 01:04:54 -08:00
Jack Kingsman
465091d370 Move all tests to using actual memory DB 2026-02-12 00:55:40 -08:00
Jack Kingsman
2248a13cde Purge dead WS handlers from back when we loaded contacts + chans over WS not API 2026-02-12 00:37:49 -08:00
Jack Kingsman
7e7330eb12 Add more tests and update AGENTS.md 2026-02-12 00:31:52 -08:00
Jack Kingsman
b80093ba94 Make mark-all-read atomic 2026-02-12 00:19:37 -08:00
Jack Kingsman
b83edfda74 Investigate all fields on reconcile calls 2026-02-12 00:18:58 -08:00
Jack Kingsman
4e2000087d Handle nullable contact fields now that we don't send partial contacts 2026-02-12 00:17:37 -08:00
Jack Kingsman
8be5a22730 Fix path data update race condition 2026-02-12 00:15:21 -08:00
Jack Kingsman
6ac5a1e7db Fix stale header retention on settings page mobile view 2026-02-12 00:12:47 -08:00
Jack Kingsman
fe6dfd5dee Fix mention detection case sensitivity 2026-02-12 00:01:27 -08:00
Jack Kingsman
f73fa54532 Outgoing WS now echoes, websock reclamation after unmount cleanup, hash fix for empty contacts, no double bot broadcast, AGENTS.md + test fixes (this should have been more than one commit lol) 2026-02-11 23:59:05 -08:00
Jack Kingsman
fef18a943c Merge pull request #17 from jkingsman/better-options
Way better options dialog!
2026-02-11 22:58:01 -08:00
Jack Kingsman
f3c3b84210 Way better options dialog 2026-02-11 22:57:43 -08:00
Jack Kingsman
29f0518698 Do testing on isolated DB 2026-02-11 11:23:38 -08:00
Jack Kingsman
f52feb2d5c Add a smatttering of tests and fix return-to-public after channel deletion 2026-02-10 23:02:39 -08:00
Jack Kingsman
6696681f63 Updating changelog + build for 1.9.1 2026-02-10 22:12:16 -08:00
Jack Kingsman
1aa26c05d0 Don't use prefix matching if we can help it 2026-02-10 22:05:59 -08:00
Jack Kingsman
bfdccc4a94 Use stable referrent for same-name contacts 2026-02-10 21:47:41 -08:00
Jack Kingsman
577697380b Async bot invocation -- hopefully won't have too bad of user collisions 2026-02-10 21:41:44 -08:00
Jack Kingsman
bb1709a648 Lint missed JS 2026-02-10 21:17:26 -08:00
Jack Kingsman
a1c95abf86 Add comments around edge cases as non-issues 2026-02-10 21:15:26 -08:00
Jack Kingsman
e96b37a263 Fix phantom unread badges on convo view 2026-02-10 21:13:44 -08:00
Jack Kingsman
84d39992e2 Better message length detection for multibyte sequences 2026-02-10 21:11:29 -08:00
Jack Kingsman
a61e271e9a Fix falsy zero lat/lon bug 2026-02-10 21:09:29 -08:00
Jack Kingsman
a1e71922bc Updating changelog + build for 1.9.0 2026-02-10 20:49:18 -08:00
Jack Kingsman
fa37ff6286 Add better no-frontend-dir handling (and lint, whoops) 2026-02-10 20:41:56 -08:00
Jack Kingsman
a157390fb7 Add experimental double send 2026-02-10 20:33:14 -08:00
Jack Kingsman
6b5e9457a1 Merge pull request #16 from jkingsman/improved-lockig
Rework radio lock handling
2026-02-10 19:44:08 -08:00
Jack Kingsman
d5b176cf89 Add message to clarify when no repeats heard 2026-02-10 19:28:24 -08:00
Jack Kingsman
da558e06ae Fix repeater comms coming back in different channel if a user sends and changes convos rapidly. 2026-02-10 19:25:35 -08:00
Jack Kingsman
b8ea31666f Rework radio lock handling 2026-02-10 19:15:44 -08:00
Jack Kingsman
de683e77ab Rework styling on the message path info 2026-02-10 19:13:53 -08:00
Jack Kingsman
430846a581 Fix missing scroll back to bottom 2026-02-10 18:48:12 -08:00
Jack Kingsman
875f197812 Testing blitz! 2026-02-10 16:47:46 -08:00
Jack Kingsman
cf6df506d1 Always load contacts on radio first 2026-02-10 16:19:42 -08:00
Jack Kingsman
6389cc656e Ads echo paths modal 2026-02-10 15:18:52 -08:00
Jack Kingsman
28069ab732 Test and linting tidy-up 2026-02-09 22:37:50 -08:00
Jack Kingsman
d5a321f314 Do build before e2e tests and bump timeout for build 2026-02-09 21:09:20 -08:00
Jack Kingsman
4d2db4f789 Safer reboot polling 2026-02-09 20:30:53 -08:00
Jack Kingsman
25f85d0856 Safer mention checking 2026-02-09 20:27:49 -08:00
Jack Kingsman
cf215c4ba5 Document and patch historical prefix claiming, and handle reconnect interval better 2026-02-09 20:25:21 -08:00
Jack Kingsman
9294ffe138 Fix logic for oldest-message detection on new message in focused convo and then paging 2026-02-09 19:56:20 -08:00
Jack Kingsman
ec97dfcee5 Flush through all contacts with more care to prevent rerenders 2026-02-09 19:52:41 -08:00
Jack Kingsman
d0c5c09493 Add channel lock when we set-and-send, and fail hard if channel set fails 2026-02-09 19:49:00 -08:00
Jack Kingsman
615fc0e6a7 Vacuum on own connection to avoid partial commits 2026-02-09 19:42:16 -08:00
Jack Kingsman
bfcd2f9136 Attempt auto-reconnect more than once 2026-02-09 19:40:52 -08:00
Jack Kingsman
6cacd741b7 Patch up frontend traversability (moot in the face of bot logic, but nice to have) 2026-02-09 19:33:23 -08:00
Jack Kingsman
333d885bdb Sweeping AGENTS.md updates -- endpoints, feature priorities, etc. 2026-02-09 19:24:45 -08:00
Jack Kingsman
18bdc0f83b Clarify security model for agents 2026-02-09 17:49:27 -08:00
jkingsman
18c5c045fe Fix typo lol 2026-02-09 17:14:56 -08:00
Jack Kingsman
254705c4f6 Merge pull request #14 from jkingsman/support-tcp-and-ble-connections-on-backend
Move to multi-connection modality
2026-02-09 17:07:24 -08:00
Jack Kingsman
f81d3c8dd1 Remove stale frontend generated files 2026-02-09 17:07:14 -08:00
Jack Kingsman
d3494448a0 Add hot conversation LRU cache
Add hot-message cache
2026-02-09 17:06:32 -08:00
Jack Kingsman
09e591684a Make conversation ref handling safer 2026-02-09 17:04:32 -08:00
Jack Kingsman
a636eeb4b7 Add hot-message cache 2026-02-07 22:51:06 -08:00
Jack Kingsman
93362808be Updating changelog + build for 1.8.0 2026-02-07 21:45:46 -08:00
Jack Kingsman
73793f9678 Updating changelog + build for 1.8.0 2026-02-07 21:43:39 -08:00
Jack Kingsman
05cac29556 Swap legend columns (oops) 2026-02-07 21:31:12 -08:00
Jack Kingsman
f7792bd75e Patch up changes -- fix up docs, fix react hooks issue 2026-02-07 21:25:19 -08:00
Jack Kingsman
a9d9d7a7b4 Clear errors on settings tab switch, streamline message mark-as-read, don't resort on render 2026-02-07 21:15:47 -08:00
Jack Kingsman
0abe2194b5 Just flood advertise when we do 2026-02-07 21:15:46 -08:00
Jack Kingsman
82285f07f3 Clarify garbage comment about outgoing decryption 2026-02-07 21:15:46 -08:00
Jack Kingsman
f0bdf34e6a Strengthen README warning about getting pwnd 2026-02-07 21:15:46 -08:00
jkingsman
3c9d2dbb11 Don't keep pacakge-lock.json 2026-02-06 19:34:10 -08:00
Jack Kingsman
0602ce7002 Merge pull request #10 from rgregg/iphone-pwa-fix
Fix display issues when running as a PWA
2026-02-06 19:30:50 -08:00
jkingsman
be1a70eb10 Remove compiled files 2026-02-06 19:30:26 -08:00
Jack Kingsman
df0ffd9234 Move to manual FE builds 2026-02-06 18:44:08 -08:00
Ryan Gregg
61de2584e9 Fix for running as PWA 2026-02-04 21:45:30 -08:00
Ryan Gregg
89a2fca7fd Adjust how app dispalys as PWA to enable safe areas 2026-02-04 21:24:43 -08:00
Jack Kingsman
d03b65cf39 Add there-and-back node trace. Closes #4.
Single node trace
2026-02-04 19:54:01 -08:00
Jack Kingsman
18d800da63 Add error bubble-up 2026-02-04 19:27:45 -08:00
Jack Kingsman
c2040ea6bc Add one hop trace 2026-02-04 15:40:37 -08:00
Jack Kingsman
a86d2d7cda Move to multi-connection modality 2026-02-04 14:46:41 -08:00
Jack Kingsman
64d261e6f9 Update AGENTS.md after post connect polling management 2026-02-04 13:14:08 -08:00
Jack Kingsman
6491a54ce8 Improve test coverage for path freshness, unread management, and outgoing message unread sanity 2026-02-04 12:19:36 -08:00
Jack Kingsman
5268e889fa Consolidate healthcheck generation and packet dupe handling 2026-02-04 12:12:56 -08:00
Jack Kingsman
ad5e799a62 Dead and trivial code rip out (whoof) 2026-02-04 12:06:36 -08:00
Jack Kingsman
edfc95a2e2 Add docs for new non-WS message fetching and remove dead funcs 2026-02-04 11:59:12 -08:00
Jack Kingsman
122109dc58 Add missing frontend test folder to ignore 2026-02-04 11:16:50 -08:00
Jack Kingsman
8690613bc7 Add e2e tests 2026-02-03 16:00:00 -08:00
Jack Kingsman
b302bd74ff Improve frontend response time 2026-02-03 15:58:52 -08:00
Jack Kingsman
c167e59a64 Updating changelog + build for 1.7.1 2026-02-03 10:27:27 -08:00
Jack Kingsman
71872517e5 Cleanups: Normalize pub keys, prefix message claiming, cursor + null timestamp DB cleanups 2026-02-02 16:22:10 -08:00
Jack Kingsman
8674e804c2 Add some additional tests around radio and contact management 2026-01-30 21:45:50 -08:00
Jack Kingsman
49bcdc48e7 Add some additional tests around message dedupe 2026-01-30 21:31:34 -08:00
Jack Kingsman
c048c435c9 Add missing key normalization 2026-01-30 21:12:32 -08:00
Jack Kingsman
1ea809c4e3 Remove some unneeded duplication and fix up reconnection management 2026-01-30 21:03:58 -08:00
Jack Kingsman
b6c3e13234 Resync settings after radio mutation 2026-01-30 20:44:37 -08:00
Jack Kingsman
c24d70186a Fix up some warnings 2026-01-30 20:44:36 -08:00
Jack Kingsman
0188accb46 Make links clickable 2026-01-30 20:44:36 -08:00
Jack Kingsman
eb3686e041 Fix verbiage around bot response to self 2026-01-30 20:44:30 -08:00
Jack Kingsman
f870d0e67f Enable bot responses to ourselves take 2 2026-01-29 12:32:36 -08:00
Jack Kingsman
302973cc44 Enable bot responses to ourselves 2026-01-29 12:15:30 -08:00
Jack Kingsman
bcde4dd12e Updating changelog + build for 1.7.0 2026-01-27 19:24:50 -08:00
Jack Kingsman
ccafd70388 Add constraints on bot code editor window size 2026-01-27 19:21:38 -08:00
Jack Kingsman
6ef6ca4553 Lazy-load codemirror for bot editing 2026-01-27 17:31:34 -08:00
Jack Kingsman
d6e5130f49 Move advertise to identity tab 2026-01-27 17:25:56 -08:00
Jack Kingsman
530179fde1 Add multibot functionality 2026-01-27 17:23:38 -08:00
Jack Kingsman
c46128e2cd Fix bug with outbound message timestamping 2026-01-27 13:06:00 -08:00
Jack Kingsman
bcf7d70ee8 Support multiple outgoing messages from the bot 2026-01-27 12:21:00 -08:00
Jack Kingsman
749cc85f3c Fix occasional outgoing message dupe glitch due to mismatched send vs. radio timestamps 2026-01-27 12:05:15 -08:00
Jack Kingsman
58eeb392ad Add bot outgoing 2s throttle 2026-01-27 11:54:22 -08:00
Jack Kingsman
00697e3c06 Fix clock filtering and contact lookup behavior bugs 2026-01-26 22:51:02 -08:00
Jack Kingsman
2b681e1905 Fix repeater message duplication issue and clarify fallback functionality for missing private key export 2026-01-26 22:13:44 -08:00
Jack Kingsman
d77523c76a Updating changelog + build for 1.6.0 2026-01-26 21:53:07 -08:00
Jack Kingsman
7d94a01f8a Add warning to bot interface (experimental) 2026-01-26 21:50:48 -08:00
Jack Kingsman
d97fa3703f Add automatic bot functionality
Add basic bot functionality
2026-01-26 21:46:26 -08:00
Jack Kingsman
b8c22c0293 Fix startup advertisement-without-checking-advert-rules bug 2026-01-26 21:45:58 -08:00
Jack Kingsman
ba92aa2342 Add basic bot functionality 2026-01-26 21:44:11 -08:00
Jack Kingsman
bbf6c63a95 Force DM contact onto radio before send 2026-01-26 20:54:50 -08:00
Jack Kingsman
769f34ebfc Force auto-advert to respect set intervals 2026-01-26 20:34:35 -08:00
Jack Kingsman
4382f4ab74 Remove some unneeded code 2026-01-26 20:22:19 -08:00
Jack Kingsman
22eb828b3c Drop interval suggestion to 24hr 2026-01-25 20:17:21 -08:00
Jack Kingsman
375ee74eb3 Make advert interval manual 2026-01-25 09:29:56 -08:00
Jack Kingsman
b3c26507f4 Draggable nodes and periodic (hourly) announce 2026-01-22 22:39:34 -08:00
Jack Kingsman
775b128851 Ass experimental path disambiguation on visualizer 2026-01-22 21:58:06 -08:00
Jack Kingsman
50aa9a950d Fix flag emoji 'profile picture' rendering 2026-01-22 20:13:27 -08:00
Jack Kingsman
184ef84af1 Update readme for CLAUDE.md => AGENTS.md 2026-01-20 17:25:03 -08:00
Jack Kingsman
2fb5100745 Add clear button 2026-01-20 16:54:11 -08:00
Jack Kingsman
973d6953a1 Standardize on AGENTS.md 2026-01-20 15:59:20 -08:00
Jack Kingsman
345244e8d9 Add heuristic repeater disambiguation to visualizer 2026-01-19 23:12:58 -08:00
Jack Kingsman
e16c9b625f Extract pubkey from anonreq 2026-01-19 22:57:25 -08:00
Jack Kingsman
cec6c942bd Updating changelog + build for 1.5.0 2026-01-19 22:43:28 -08:00
Jack Kingsman
5a6b98a5d8 Add visualizer
Merge in Visualizer
2026-01-19 22:39:51 -08:00
414 changed files with 67059 additions and 11892 deletions

View File

@@ -29,6 +29,7 @@ frontend/src/test/
# Docs
*.md
!README.md
!LICENSES.md
# Other
references/

8
.gitignore vendored
View File

@@ -8,8 +8,14 @@ wheels/
# Virtual environments
.venv
frontend/node_modules/
frontend/package-lock.json
frontend/test-results/
# reference librarys
# Frontend build output (built from source by end users)
frontend/dist/
frontend/.eslintcache
# reference libraries
references/
# ancillary LLM files

View File

@@ -1 +0,0 @@
3.12

437
AGENTS.md Normal file
View File

@@ -0,0 +1,437 @@
# RemoteTerm for MeshCore
## Important Rules
**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, run:
```bash
./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 these primary AGENTS.md files:**
- `app/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption)
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
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) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ CrackerPanel (global collapsible, WebGPU cracking) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ useWebSocket ←──── Real-time updates │
│ │ │
│ api.ts ←──── REST API calls │
└───────────────────────────┼──────────────────────────────────────┘
│ HTTP + WebSocket (/api/*)
┌───────────────────────────┼──────────────────────────────────────┐
│ Backend (FastAPI) │
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
│ ↓ └───────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ RadioManager + Event Handlers │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────┼──────────────────────────────────────┘
│ Serial / TCP / BLE
┌──────┴──────┐
│ MeshCore │
│ Radio │
└─────────────┘
```
## Feature Priority
**Primary (must work correctly):**
- Sending and receiving direct messages and channel messages
- Accurate message display: correct ordering, deduplication, pagination/history loading, and real-time updates without data loss or duplicates
- Accurate ACK tracking, repeat/echo counting, and path display
- Historical packet decryption (recovering incoming messages using newly-added keys)
- Outgoing DMs are stored as plaintext by the send endpoint — no decryption needed
**Secondary:**
- Channel key cracker (WebGPU brute-force)
- Repeater management (telemetry, CLI commands, ACL)
**Tertiary (best-effort, quality-of-life):**
- 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
- 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.
## 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; optional MQTT forwarding
5. **Offline-capable**: Radio operates independently; server syncs when connected
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
## 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/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.
## 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.
- `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 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()`
4. Message stored in database with `outgoing=true`
5. For direct messages: ACK tracked; for channel: repeat detection
### ACK and Repeat Detection
**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` 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
```
.
├── app/ # FastAPI backend
│ ├── AGENTS.md # Backend documentation
│ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints
│ ├── 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
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── frontend/ # React frontend
│ ├── AGENTS.md # Frontend documentation
│ ├── src/
│ │ ├── App.tsx # Main component
│ │ ├── api.ts # REST client
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── components/
│ │ ├── CrackerPanel.tsx # WebGPU key cracking
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ...
│ └── vite.config.ts
├── 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
```
## Development Setup
### Backend
```bash
# Install dependencies
uv sync
# Run server (auto-detects radio)
uv run uvicorn app.main:app --reload
# Or specify port
MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173, proxies /api to :8000
```
### Both Together (Development)
Terminal 1: `uv run uvicorn app.main:app --reload`
Terminal 2: `cd frontend && npm run dev`
### Production
In production, the FastAPI backend serves the compiled frontend. Build the frontend first:
```bash
cd frontend && npm install && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access at `http://localhost:8000`. All API routes are prefixed with `/api`.
If `frontend/dist` (or `frontend/dist/index.html`) is missing, backend startup now logs an explicit error and continues serving API routes. In that case, frontend static routes are not mounted until a frontend build is present.
## Testing
### Backend (pytest)
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
- `tests/test_keystore.py` - Ephemeral key store
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_packet_pipeline.py` - End-to-end packet processing
- `tests/test_api.py` - API endpoints, read state tracking
- `tests/test_migrations.py` - Database migration system
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
- `tests/test_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_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)
```bash
cd frontend
npm run test:run
```
### Before Completing Changes
**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
All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| 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/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/{public_key}` | Delete contact |
| POST | `/api/contacts/sync` | Pull from radio |
| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio |
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{public_key}/reset-path` | Reset contact path to flood |
| POST | `/api/contacts/{public_key}/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}/mark-read` | Mark channel as read |
| 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 |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times |
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
| GET | `/api/statistics` | Aggregated mesh network statistics |
| WS | `/api/ws` | Real-time updates |
## Key Concepts
### Contact Public Keys
- Full key: 64-character hex string
- Prefix: 12-character hex (used for matching)
- Lookups use `LIKE 'prefix%'` for matching
### Contact Types
- `0` - Unknown
- `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
### Message Types
- `PRIV` - Direct messages
- `CHAN` - Channel messages
- Both use `conversation_key` (user pubkey for PRIV, channel key for CHAN)
### Read State Tracking
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/{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)
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
- Channels: `channel-{channel_key}`
- Contacts: `contact-{full-public-key}`
**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.
**Channel messages**: Decrypted automatically when a matching channel key is available.
**Direct messages**: Decrypted server-side using the private key exported from the radio on startup. This enables DM decryption even when the contact isn't loaded on the radio. The private key is stored in memory only (see `keystore.py`).
## MeshCore Library
The `meshcore_py` library provides radio communication. Key patterns:
```python
# Connection
mc = await MeshCore.create_serial(port="/dev/ttyUSB0")
# Commands
await mc.commands.send_msg(dst, msg)
await mc.commands.send_chan_msg(channel_idx, msg)
await mc.commands.get_contacts()
await mc.commands.set_channel(idx, name, key)
# Events
mc.subscribe(EventType.CONTACT_MSG_RECV, handler)
mc.subscribe(EventType.CHANNEL_MSG_RECV, handler)
mc.subscribe(EventType.ACK, handler)
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_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) |
**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`. 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`.
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.

View File

@@ -1,3 +1,260 @@
## [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
## [1.9.2] - 2026-02-12
Feature: Options dialog sucks less
Bugfix: Move tests to isolated memory DB
Bugfix: Mention case sensitivity
Bugfix: Stale header retention on settings page view
Bugfix: Non-isolated path writing
Bugfix: Nullable contact fields are now passed as real nulls
Bugfix: Look at all fields on message reconcile, not just text
Bugfix: Make mark-all-as-read atomic
Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
Misc: Massive test and AGENTS.md overhauls and additions
## [1.9.1] - 2026-02-10
Feature: Contacts and channels use keys, not names
Bugfix: Fix falsy casting of 0 in lat lon and timing data
Bugfix: Show message length in bytes, not chars
Bugfix: Fix phantom unread badges on focused convos
Misc: Bot invocation to async
Misc: Use full key, not prefix, where we can
## [1.9.0] - 2026-02-10
Feature: Favorited contacts are preferentially loaded onto the radio
Feature: Add recent-message caching for fast switching
Feature: Add echo paths modal when echo-heard checkbox is clicked
Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
Frontend: Better styling on echo + message path display
Bugfix: Prevent frontend static file serving path traversal vuln
Bugfix: Safer prefix-claiming for DMs we don't have the key for
Bugfix: Prevent injection from mentions with special characters
Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
Bugfix: App can boot and test without a frontend dir
Misc: Improve and consistent-ify (?) backend radio operation lock management
Misc: Frontend performance and safety enhancements
Misc: Move builds to non-bundled; usage requires building the Frontend
Misc: Update tests and agent docs
## [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 consistent prefix-based message claiming on key receipt
Misc: Bot can respond to its own messages
Misc: Additional tests
Misc: Remove unneeded message dedupe logic
Misc: Resync settings after radio settings mutation
## [1.7.0] - 2026-01-27
Feature: Multi-bot functionality
Bugfix: Adjust bot code editor display and add line numbers
Bugfix: Fix clock filtering and contact lookup behavior bugs
Bugfix: Fix repeater message duplication issue
Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
UI: Move advertise button to identity tab
Misc: Clarify fallback functionality for missing private key export in logs
## [1.6.0] - 2026-01-26
Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
Feature: Customizable advertising interval
Feature: In-app bot setup
Bugfix: Force contact onto radio before DM send
Misc: Remove unused code
## [1.5.0] - 2026-01-19
Feature: Network visualizer
## [1.4.1] - 2026-01-19
Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)

316
CLAUDE.md
View File

@@ -1,315 +1,5 @@
# RemoteTerm for MeshCore
## Important Rules
**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:
```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
```
## Overview
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over serial and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
**For detailed component documentation, see:**
- `app/CLAUDE.md` - Backend (FastAPI, database, radio connection, packet decryption)
- `frontend/CLAUDE.md` - Frontend (React, state management, WebSocket, components)
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ CrackerPanel (global collapsible, WebGPU cracking) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ useWebSocket ←──── Real-time updates │
│ │ │
│ api.ts ←──── REST API calls │
└───────────────────────────┼──────────────────────────────────────┘
│ HTTP + WebSocket (/api/*)
┌───────────────────────────┼──────────────────────────────────────┐
│ Backend (FastAPI) │
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
│ ↓ └───────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ RadioManager + Event Handlers │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────┼──────────────────────────────────────┘
│ Serial
┌──────┴──────┐
│ MeshCore │
│ Radio │
└─────────────┘
```
## 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
5. **Offline-capable**: Radio operates independently; server syncs when connected
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
## 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
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()`
4. Message stored in database with `outgoing=true`
5. For direct messages: ACK tracked; for channel: repeat detection
### ACK and Repeat Detection
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
**Channel messages**: Flood messages echo back. The decoder identifies repeats by matching (channel_idx, text_hash, timestamp ±5s) and marks the original as "acked".
## Directory Structure
```
.
├── app/ # FastAPI backend
│ ├── CLAUDE.md # Backend documentation
│ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints
│ ├── repository.py # Database CRUD
│ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption
│ └── websocket.py # Real-time broadcasts
├── frontend/ # React frontend
│ ├── CLAUDE.md # Frontend documentation
│ ├── src/
│ │ ├── App.tsx # Main component
│ │ ├── api.ts # REST client
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── components/
│ │ ├── CrackerPanel.tsx # WebGPU key cracking
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ...
│ └── vite.config.ts
├── references/meshcore_py/ # MeshCore Python library
├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime)
├── integration_test.html # Browser-based API tests
└── pyproject.toml # Python dependencies
```
## Development Setup
### Backend
```bash
# Install dependencies
uv sync
# Run server (auto-detects radio)
uv run uvicorn app.main:app --reload
# Or specify port
MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173, proxies /api to :8000
```
### Both Together (Development)
Terminal 1: `uv run uvicorn app.main:app --reload`
Terminal 2: `cd frontend && npm run dev`
### Production
In production, the FastAPI backend serves the compiled frontend:
```bash
cd frontend && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access at `http://localhost:8000`. All API routes are prefixed with `/api`.
## Testing
### Backend (pytest)
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
- `tests/test_keystore.py` - Ephemeral key store
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_api.py` - API endpoints, read state tracking
- `tests/test_migrations.py` - Database migration system
### Frontend (Vitest)
```bash
cd frontend
npm run test:run
```
### Integration Tests
Open `integration_test.html` in a browser with the backend running.
### 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
## API Summary
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 |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| PUT | `/api/radio/private-key` | Import private key to radio |
| GET | `/api/contacts` | List contacts |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| POST | `/api/contacts/sync` | Pull from radio |
| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater |
| POST | `/api/contacts/{key}/command` | Send CLI command to repeater |
| GET | `/api/channels` | List channels |
| POST | `/api/channels` | Create channel |
| GET | `/api/messages` | List with filters |
| POST | `/api/messages/direct` | Send direct message |
| POST | `/api/messages/channel` | Send channel message |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| GET | `/api/packets/decrypt/progress` | Get historical decryption progress |
| POST | `/api/packets/maintenance` | Delete old packets (cleanup) |
| POST | `/api/contacts/{key}/mark-read` | Mark contact conversation as read |
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
| WS | `/api/ws` | Real-time updates |
## Key Concepts
### Contact Public Keys
- Full key: 64-character hex string
- Prefix: 12-character hex (used for matching)
- Lookups use `LIKE 'prefix%'` for matching
### Contact Types
- `0` - Unknown
- `1` - Client (regular node)
- `2` - Repeater
- `3` - Room
### 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
### Message Types
- `PRIV` - Direct messages
- `CHAN` - Channel messages
- Both use `conversation_key` (user pubkey for PRIV, channel key for CHAN)
### Read State Tracking
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`
- Bulk update via `POST /api/read-state/mark-all-read`
- Frontend compares `last_read_at` with message `received_at` to count unreads
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
- Channels: `channel-{channel_key}`
- Contacts: `contact-{12-char-pubkey-prefix}`
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
### Server-Side Decryption
The server can decrypt packets using stored keys, both in real-time and for historical packets.
**Channel messages**: Decrypted automatically when a matching channel key is available.
**Direct messages**: Decrypted server-side using the private key exported from the radio on startup. This enables DM decryption even when the contact isn't loaded on the radio. The private key is stored in memory only (see `keystore.py`).
## MeshCore Library
The `meshcore_py` library provides radio communication. Key patterns:
```python
# Connection
mc = await MeshCore.create_serial(port="/dev/ttyUSB0")
# Commands
await mc.commands.send_msg(dst, msg)
await mc.commands.send_chan_msg(channel_idx, msg)
await mc.commands.get_contacts()
await mc.commands.set_channel(idx, name, key)
# Events
mc.subscribe(EventType.CONTACT_MSG_RECV, handler)
mc.subscribe(EventType.CHANNEL_MSG_RECV, handler)
mc.subscribe(EventType.ACK, handler)
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_MAX_RADIO_CONTACTS` | `200` | Max recent contacts to keep on radio for DM ACKs |
- `./AGENTS.md` (general project information)
- `app/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption)
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)

View File

@@ -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

File diff suppressed because it is too large Load Diff

197
README.md
View File

@@ -1,31 +1,37 @@
# RemoteTerm for MeshCore
Backend server + browser interface for MeshCore mesh radio networks. Attach your radio over serial, and then you can:
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 authentication. Run it on a private network only -- do not expose to the internet unless you want strangers sending traffic as you.
**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.
![Screenshot of the application's web interface](screenshot.png)
![Screenshot of the application's web interface](app_screenshot.png)
## 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 `CLAUDE.md` files: `./CLAUDE.md`, `./frontend/CLAUDE.md`, and `./app/CLAUDE.md`.
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 18+
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- MeshCore radio connected via USB serial
- MeshCore radio connected via USB serial, TCP, or BLE
<details>
<summary>Finding your serial port</summary>
**Find your serial port:**
```bash
#######
# Linux
@@ -37,55 +43,111 @@ 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>
## Quick Start
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
The frontend is pre-built -- just run the backend:
```bash
git clone https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
cd Remote-Terminal-for-MeshCore
# Install backend dependencies
uv sync
# Build frontend
cd frontend && npm install && npm run build && cd ..
# Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
The server auto-detects the serial port. To specify manually:
The server auto-detects the serial port. To specify a transport manually:
```bash
# Serial (explicit port)
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP (e.g. via wifi-enabled firmware)
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE (address and PIN both required)
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
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.
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
```bash
docker run -d \
--device=/dev/ttyUSB0 \
-v remoteterm-data:/app/data \
-p 8000:8000 \
jkingsman/remote-terminal-for-meshcore:latest
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
@@ -94,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
@@ -113,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
@@ -139,15 +216,21 @@ npm run build # build the frontend
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Baud rate |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_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) |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
## Additional Setup
<details>
<summary>HTTPS (Required for WebGPU 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:
@@ -156,21 +239,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.
@@ -179,6 +262,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
@@ -193,7 +278,7 @@ cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# Build frontend (optional -- already built in repo and served by backend)
# Build frontend (required for the backend to serve the web UI)
cd /opt/remoteterm/frontend
sudo -u remoteterm npm install
sudo -u remoteterm npm run build
@@ -215,17 +300,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.

338
app/AGENTS.md Normal file
View File

@@ -0,0 +1,338 @@
# Backend AGENTS.md
This document is the backend working guide for agents and developers.
Keep it aligned with `app/` source files and router behavior.
## Stack
- FastAPI
- aiosqlite
- Pydantic
- MeshCore Python library (`meshcore` from PyPI)
- PyCryptodome
## Backend Map
```text
app/
├── main.py # App startup/lifespan, router registration, static frontend mounting
├── 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/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
├── radio.py # RadioManager + auto-reconnect monitor
├── 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
├── websocket.py # WS manager + broadcast helpers
├── 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/
├── health.py
├── radio.py
├── contacts.py
├── channels.py
├── messages.py
├── packets.py
├── read_state.py
├── settings.py
├── fanout.py
├── repeaters.py
├── statistics.py
└── ws.py
```
## Core Runtime Flows
### Incoming data
1. Radio emits events.
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
3. Decrypted messages are inserted into `messages` and broadcast over WS.
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
### Outgoing messages
1. Send endpoints in `routers/messages.py` call MeshCore commands.
2. Message is persisted as outgoing.
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.
- Monitor reconnect path runs `post_connect_setup()` before broadcasting healthy state.
- Manual reconnect/reboot endpoints call `reconnect()` then `post_connect_setup()`.
- Setup includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
## 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.
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
### Read/unread state
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
### Echo/repeat dedup
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented **only for outgoing messages**. Incoming repeats add path data but do not change the ACK count.
### 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
- Controlled by `app_settings.advert_interval` (seconds).
- `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` — 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`
- `POST /radio/reconnect`
### 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}/command`
- `POST /contacts/{public_key}/reset-path`
- `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}/mark-read`
### 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`
- `POST /packets/decrypt/historical`
- `POST /packets/maintenance`
### Read state
- `GET /read-state/unreads`
- `POST /read-state/mark-all-read`
### Settings
- `GET /settings`
- `PATCH /settings`
- `POST /settings/favorites/toggle`
- `POST /settings/blocked-keys/toggle`
- `POST /settings/blocked-names/toggle`
- `POST /settings/migrate`
### Fanout
- `GET /fanout` — list all fanout configs
- `POST /fanout` — create new fanout config
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
- `DELETE /fanout/{id}` — delete fanout config (stops module)
### Statistics
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
### WebSocket
- `WS /ws`
## WebSocket Events
- `health` — radio connection status (broadcast on change, personal on connect)
- `contact` — single contact upsert (from advertisements and radio sync)
- `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)
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
- `channel_deleted` — channel removed from database (payload: `{ key }`)
- `error` — toast notification (reconnect failure, missing private key, etc.)
- `success` — toast notification (historical decrypt complete, etc.)
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
Client sends `"ping"` text; server replies `{"type":"pong"}`.
## Data Model Notes
Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
- `channels`
- `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`
`app_settings` fields in active model:
- `max_radio_contacts`
- `favorites`
- `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
- `last_message_times`
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `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)
- No authn/authz.
- No CORS restriction (`*`).
- Bot code executes user-provided Python via `exec()`.
These are product decisions for trusted-network deployments; do not flag as accidental vulnerabilities.
## Testing
Run backend tests:
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
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_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
When changing backend behavior:
1. Update/add router and repository tests.
2. Confirm WS event contracts when payload shape changes.
3. Run `PYTHONPATH=. uv run pytest tests/ -v`.
4. If API contract changed, update frontend types and AGENTS docs.

View File

@@ -1,642 +0,0 @@
# Backend CLAUDE.md
This document provides context for AI assistants and developers working on the FastAPI backend.
## Technology Stack
- **FastAPI** - Async web framework with automatic OpenAPI docs
- **aiosqlite** - Async SQLite driver
- **meshcore** - MeshCore radio library (local dependency at `../meshcore_py`)
- **Pydantic** - Data validation and settings management
- **PyCryptodome** - AES-128 encryption for packet decryption
- **UV** - Python package manager
## Directory Structure
```
app/
├── main.py # FastAPI app, lifespan, router registration, static file serving
├── config.py # Pydantic settings (env vars: MESHCORE_*)
├── database.py # SQLite schema, connection management, runs migrations
├── migrations.py # Database migrations using SQLite user_version pragma
├── models.py # Pydantic models for API request/response
├── repository.py # Database CRUD (ContactRepository, ChannelRepository, etc.)
├── radio.py # RadioManager - serial connection to MeshCore device
├── radio_sync.py # Periodic sync, contact auto-loading to radio
├── decoder.py # Packet decryption (channel + direct messages)
├── packet_processor.py # Raw packet processing, advertisement handling
├── keystore.py # Ephemeral key store (private key in memory only)
├── event_handlers.py # Radio event subscriptions, ACK tracking, repeat detection
├── websocket.py # WebSocketManager for real-time client updates
└── routers/ # All routes prefixed with /api
├── health.py # GET /api/health
├── radio.py # Radio config, advertise, private key, reboot
├── contacts.py # Contact CRUD, radio sync, mark-read
├── channels.py # Channel CRUD, radio sync, mark-read
├── messages.py # Message list and send (direct/channel)
├── packets.py # Raw packet endpoints, historical decryption
├── read_state.py # Bulk read state operations (mark-all-read)
├── settings.py # App settings (max_radio_contacts)
└── ws.py # WebSocket endpoint at /api/ws
```
## Key Architectural Patterns
### Repository Pattern
All database operations go through repository classes in `repository.py`:
```python
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository, AppSettingsRepository
# Examples
contact = await ContactRepository.get_by_key_prefix("abc123")
await MessageRepository.create(msg_type="PRIV", text="Hello", received_at=timestamp)
await RawPacketRepository.mark_decrypted(packet_id, message_id)
# App settings (single-row pattern)
settings = await AppSettingsRepository.get()
await AppSettingsRepository.update(auto_decrypt_dm_on_advert=True)
await AppSettingsRepository.add_favorite("contact", public_key)
await AppSettingsRepository.update_last_message_time("channel-KEY", timestamp)
```
### Radio Connection
`RadioManager` in `radio.py` handles serial connection:
```python
from app.radio import radio_manager
# Access meshcore instance
if radio_manager.meshcore:
await radio_manager.meshcore.commands.send_msg(dst, msg)
```
Auto-detection scans common serial ports when `MESHCORE_SERIAL_PORT` is not set.
### Event-Driven Architecture
Radio events flow through `event_handlers.py`:
| Event | Handler | Actions |
|-------|---------|---------|
| `CONTACT_MSG_RECV` | `on_contact_message` | Store message, update contact last_seen, broadcast via WS |
| `CHANNEL_MSG_RECV` | `on_channel_message` | Store message, broadcast via WS |
| `RAW_DATA` | `on_raw_data` | Store packet, try decrypt with all channel keys, detect repeats |
| `ADVERTISEMENT` | `on_advertisement` | Upsert contact with location |
| `ACK` | `on_ack` | Match pending ACKs, mark message acked, broadcast |
### WebSocket Broadcasting
Real-time updates use `ws_manager` singleton:
```python
from app.websocket import ws_manager
# Broadcast to all connected clients
await ws_manager.broadcast("message", {"id": 1, "text": "Hello"})
```
Event types: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
Helper functions for common broadcasts:
```python
from app.websocket import broadcast_error, broadcast_health
# Notify clients of errors (shows toast in frontend)
broadcast_error("Operation failed", "Additional details")
# Notify clients of connection status change
broadcast_health(radio_connected=True, serial_port="/dev/ttyUSB0")
```
### Connection Monitoring
`RadioManager` includes a background task that monitors connection status:
- Checks connection every 5 seconds
- Broadcasts `health` event on status change
- Attempts automatic reconnection when connection lost
- **Re-registers event handlers after successful auto-reconnect** (critical for message delivery)
- Resilient to transient errors (logs and continues rather than crashing)
- Supports manual reconnection via `POST /api/radio/reconnect`
```python
from app.radio import radio_manager
# Manual reconnection
success = await radio_manager.reconnect()
# Background monitor (started automatically in app lifespan)
await radio_manager.start_connection_monitor()
await radio_manager.stop_connection_monitor()
```
### Message Polling
Periodic message polling serves as a fallback for platforms where push events are unreliable.
Use `pause_polling()` to temporarily suspend polling during operations that need exclusive
radio access (e.g., repeater CLI commands):
```python
from app.radio_sync import pause_polling, is_polling_paused
# Pause polling during sensitive operations (supports nesting)
async with pause_polling():
# Polling is paused here
await do_repeater_operation()
async with pause_polling():
# Still paused (nested)
await do_another_operation()
# Still paused (outer context active)
# Polling resumes when all contexts exit
# Check current state
if is_polling_paused():
print("Polling is currently paused")
```
## Database Schema
```sql
contacts (
public_key TEXT PRIMARY KEY, -- 64-char hex
name TEXT,
type INTEGER DEFAULT 0, -- 0=unknown, 1=client, 2=repeater, 3=room
flags INTEGER DEFAULT 0,
last_path TEXT, -- Routing path hex
last_path_len INTEGER DEFAULT -1,
last_advert INTEGER, -- Unix timestamp of last advertisement
lat REAL, lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0, -- Boolean: contact loaded on radio
last_contacted INTEGER, -- Unix timestamp of last message sent/received
last_read_at INTEGER -- Unix timestamp when conversation was last read
)
channels (
key TEXT PRIMARY KEY, -- 32-char hex channel key
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0, -- Key derived from SHA256(name)[:16]
on_radio INTEGER DEFAULT 0,
last_read_at INTEGER -- Unix timestamp when channel was last read
)
messages (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL, -- 'PRIV' or 'CHAN'
conversation_key TEXT NOT NULL, -- User pubkey for PRIV, channel key for CHAN
text TEXT NOT NULL,
sender_timestamp INTEGER,
received_at INTEGER NOT NULL,
path TEXT, -- Hex-encoded routing path (2 chars per hop), null for outgoing
txt_type INTEGER DEFAULT 0,
signature TEXT,
outgoing INTEGER DEFAULT 0,
acked INTEGER DEFAULT 0,
UNIQUE(type, conversation_key, text, sender_timestamp) -- Deduplication
)
raw_packets (
id INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL,
data BLOB NOT NULL, -- Raw packet bytes
decrypted INTEGER DEFAULT 0,
message_id INTEGER, -- FK to messages if decrypted
decrypt_attempts INTEGER DEFAULT 0,
last_attempt INTEGER,
FOREIGN KEY (message_id) REFERENCES messages(id)
)
app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single-row pattern
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]', -- JSON array of {type, id}
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent', -- 'recent' or 'alpha'
last_message_times TEXT DEFAULT '{}', -- JSON object of state_key -> timestamp
preferences_migrated INTEGER DEFAULT 0 -- One-time migration flag
)
```
## Database Migrations (`migrations.py`)
Schema migrations use SQLite's `user_version` pragma for version tracking:
```python
from app.migrations import get_version, set_version, run_migrations
# Check current schema version
version = await get_version(conn) # Returns int (0 for new/unmigrated DB)
# Run pending migrations (called automatically on startup)
applied = await run_migrations(conn) # Returns number of migrations applied
```
### How It Works
1. `database.py` calls `run_migrations()` after schema initialization
2. Each migration checks `user_version` and runs if needed
3. Migrations are idempotent (safe to run multiple times)
4. `ALTER TABLE ADD COLUMN` handles existing columns gracefully
### Adding a New Migration
```python
# In migrations.py
async def run_migrations(conn: aiosqlite.Connection) -> int:
version = await get_version(conn)
applied = 0
if version < 1:
await _migrate_001_add_last_read_at(conn)
await set_version(conn, 1)
applied += 1
# Add new migrations here:
# if version < 2:
# await _migrate_002_something(conn)
# await set_version(conn, 2)
# applied += 1
return applied
```
## Packet Decryption (`decoder.py`)
The decoder handles MeshCore packet decryption for historical packet analysis:
### Packet Types
```python
class PayloadType(IntEnum):
GROUP_TEXT = 0x05 # Channel messages (decryptable)
TEXT_MESSAGE = 0x02 # Direct messages
ACK = 0x03
ADVERT = 0x04
# ... see decoder.py for full list
```
### Channel Key Derivation
Hashtag channels derive keys from name:
```python
channel_key = hashlib.sha256(b"#channelname").digest()[:16]
```
### Decryption Flow
1. Parse packet header to get payload type
2. For `GROUP_TEXT`: extract channel_hash (1 byte), cipher_mac (2 bytes), ciphertext
3. Verify HMAC-SHA256 using 32-byte secret (key + 16 zero bytes)
4. Decrypt with AES-128 ECB
5. Parse decrypted content: timestamp (4 bytes), flags (1 byte), "sender: message" text
```python
from app.decoder import try_decrypt_packet_with_channel_key
result = try_decrypt_packet_with_channel_key(raw_bytes, channel_key)
if result:
print(f"{result.sender}: {result.message}")
```
### Direct Message Decryption
Direct messages use ECDH key exchange (Ed25519 → X25519) for shared secret derivation.
**Key storage**: The private key is exported from the radio on startup and stored in memory
via `keystore.py`. This enables server-side DM decryption even when contacts aren't loaded
on the radio.
**Real-time decryption**: When a `RAW_DATA` event contains a `TEXT_MESSAGE` packet, the
`packet_processor.py` attempts to decrypt it using known contact public keys and the
stored private key.
**Historical decryption**: When creating a contact with `try_historical=True`, the server
attempts to decrypt all stored `TEXT_MESSAGE` packets for that contact.
**Direction detection**: The decoder uses the 1-byte dest_hash and src_hash to determine
if a message is incoming or outgoing. Edge case: when both bytes match (1/256 chance),
defaults to treating as incoming.
```python
from app.decoder import try_decrypt_dm
result = try_decrypt_dm(raw_bytes, private_key, contact_public_key)
if result:
print(f"{result.message} (timestamp={result.timestamp})")
```
## Advertisement Parsing (`decoder.py`)
Advertisement packets contain contact information including optional GPS coordinates.
### Packet Structure
```
Bytes 0-31: Public key (32 bytes)
Bytes 32-35: Timestamp (4 bytes, little-endian Unix timestamp)
Bytes 36-99: Signature (64 bytes)
Byte 100: App flags
Bytes 101+: Optional fields (location, name) based on flags
```
### App Flags (byte 100)
- Bits 0-3: Device role (1=Chat, 2=Repeater, 3=Room, 4=Sensor)
- Bit 4: Has location (lat/lon follow)
- Bit 5: Has feature 1
- Bit 6: Has feature 2
- Bit 7: Has name (null-terminated string at end)
### GPS Extraction
When bit 4 is set, latitude and longitude follow as signed int32 little-endian values,
divided by 1,000,000 to get decimal degrees:
```python
from app.decoder import parse_advertisement
advert = parse_advertisement(payload_bytes)
if advert:
print(f"Device role: {advert.device_role}") # 1=Chat, 2=Repeater
if advert.lat and advert.lon:
print(f"Location: {advert.lat}, {advert.lon}")
```
### Data Flow
1. `event_handlers.py` receives ADVERTISEMENT event
2. `packet_processor.py` calls `parse_advertisement()` to extract data
3. Contact is upserted with location data (`lat`, `lon`) and `device_role` as `type`
4. Frontend MapView displays contacts with GPS coordinates
## ACK and Repeat Detection
The `acked` field is an integer count, not a boolean:
- `0` = not acked
- `1` = one ACK/echo received
- `2+` = multiple flood echoes received
### Direct Message ACKs
When sending a direct message, an expected ACK code is tracked:
```python
from app.event_handlers import track_pending_ack
track_pending_ack(expected_ack="abc123", message_id=42, timeout_ms=30000)
```
When ACK event arrives, the message's ack count is incremented.
### Channel Message Repeats
Flood messages echo back through repeaters. Detection uses:
- Channel key
- Text hash
- Timestamp (±5 second window)
Each repeat increments the ack count. The frontend displays:
- `?` = no acks
- `✓` = 1 echo
- `✓2`, `✓3`, etc. = multiple echoes (real-time updates via WebSocket)
### Auto-Contact Sync to Radio
To enable the radio to auto-ACK incoming DMs, recent non-repeater contacts are
automatically loaded to the radio. Configured via `max_radio_contacts` setting (default 200).
- Triggered on each advertisement from a non-repeater contact
- Loads most recently contacted non-repeaters (by `last_contacted` timestamp)
- Throttled to at most once per 30 seconds
- `last_contacted` updated on message send/receive
```python
from app.radio_sync import sync_recent_contacts_to_radio
result = await sync_recent_contacts_to_radio(force=True)
# Returns: {"loaded": 5, "already_on_radio": 195, "failed": 0}
```
## API Endpoints
All endpoints are prefixed with `/api`.
### Health
- `GET /api/health` - Connection status, serial port
### Radio
- `GET /api/radio/config` - Read config (public key, name, radio params)
- `PATCH /api/radio/config` - Update name, lat/lon, tx_power, radio params
- `PUT /api/radio/private-key` - Import private key to radio (write-only)
- `POST /api/radio/advertise?flood=true` - Send advertisement
- `POST /api/radio/reboot` - Reboot radio or reconnect if disconnected
- `POST /api/radio/reconnect` - Manual reconnection attempt
### Contacts
- `GET /api/contacts` - List from database
- `GET /api/contacts/{key}` - Get by public key or prefix
- `POST /api/contacts` - Create contact (optionally trigger historical DM decryption)
- `POST /api/contacts/sync` - Pull from radio to database
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
- `POST /api/contacts/{key}/mark-read` - Mark conversation as read (updates last_read_at)
- `POST /api/contacts/{key}/telemetry` - Request telemetry from repeater (see below)
### Channels
- `GET /api/channels` - List from database
- `GET /api/channels/{key}` - Get by channel key
- `POST /api/channels` - Create (hashtag if name starts with # or no key provided)
- `POST /api/channels/sync` - Pull from radio
- `POST /api/channels/{key}/mark-read` - Mark channel as read (updates last_read_at)
- `DELETE /api/channels/{key}` - Delete channel
### Read State
- `POST /api/read-state/mark-all-read` - Mark all contacts and channels as read
### Messages
- `GET /api/messages?type=&conversation_key=&limit=&offset=` - List with filters
- `POST /api/messages/direct` - Send direct message
- `POST /api/messages/channel` - Send channel message
### Packets
- `GET /api/packets/undecrypted/count` - Count of undecrypted packets
- `POST /api/packets/decrypt/historical` - Try decrypting old packets with new key
### Settings
- `GET /api/settings` - Get all app settings
- `PATCH /api/settings` - Update settings (max_radio_contacts, auto_decrypt_dm_on_advert, sidebar_sort_order)
- `POST /api/settings/favorites` - Add a favorite
- `DELETE /api/settings/favorites` - Remove a favorite
- `POST /api/settings/favorites/toggle` - Toggle favorite status
- `POST /api/settings/last-message-time` - Update last message time for a conversation
- `POST /api/settings/migrate` - One-time migration from frontend localStorage
### WebSocket
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
### Static Files (Production)
In production, the backend also serves the frontend:
- `/` - Serves `frontend/dist/index.html`
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
- `/*` - Falls back to `index.html` for SPA routing
## Testing
Run tests with:
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange, real-world test vectors
- `tests/test_keystore.py` - Ephemeral key store operations
- `tests/test_event_handlers.py` - ACK tracking, repeat detection, CLI response filtering
- `tests/test_api.py` - API endpoint tests, read state tracking
- `tests/test_migrations.py` - Migration system, schema versioning
## Common Tasks
### Adding a New Endpoint
1. Create or update router in `app/routers/`
2. Define Pydantic models in `app/models.py` if needed
3. Add repository methods in `app/repository.py` for database operations
4. Register router in `app/main.py` if new file
5. Add tests in `tests/`
### Adding a New Event Handler
1. Define handler in `app/event_handlers.py`
2. Register in `register_event_handlers()` function
3. Broadcast updates via `ws_manager` as needed
### Working with Radio Commands
```python
# Available via radio_manager.meshcore.commands
await mc.commands.send_msg(dst, msg)
await mc.commands.send_chan_msg(chan, msg)
await mc.commands.get_contacts()
await mc.commands.add_contact(contact_dict)
await mc.commands.set_channel(idx, name, key)
await mc.commands.send_advert(flood=True)
```
## Repeater Telemetry
The `POST /api/contacts/{key}/telemetry` endpoint fetches status, neighbors, and ACL from repeaters (contact type=2).
### Request Flow
1. Verify contact exists and is a repeater (type=2)
2. Add contact to radio with stored path data (from advertisements)
3. Send login with password
4. Request status with retries (3 attempts, 10s timeout)
5. Fetch neighbors with `fetch_all_neighbours()` (handles pagination)
6. Fetch ACL with `req_acl_sync()`
7. Resolve pubkey prefixes to contact names from database
### ACL Permission Levels
```python
ACL_PERMISSION_NAMES = {
0: "Guest",
1: "Read-only",
2: "Read-write",
3: "Admin",
}
```
### Response Models
```python
class NeighborInfo(BaseModel):
pubkey_prefix: str # 4-12 char prefix
name: str | None # Resolved contact name
snr: float # Signal-to-noise ratio in dB
last_heard_seconds: int # Seconds since last heard
class AclEntry(BaseModel):
pubkey_prefix: str # 12 char prefix
name: str | None # Resolved contact name
permission: int # 0-3
permission_name: str # Human-readable name
class TelemetryResponse(BaseModel):
# Status fields
pubkey_prefix: str
battery_volts: float # Converted from mV
uptime_seconds: int
# ... signal quality, packet counts, etc.
# Related data
neighbors: list[NeighborInfo]
acl: list[AclEntry]
```
## Repeater CLI Commands
After login via telemetry endpoint, you can send CLI commands to repeaters:
### Endpoint
`POST /api/contacts/{key}/command` - Send a CLI command (assumes already logged in)
### Request/Response
```python
class CommandRequest(BaseModel):
command: str # CLI command to send
class CommandResponse(BaseModel):
command: str # Echo of sent command
response: str # Response from repeater
sender_timestamp: int | None # Timestamp from response
```
### Common Commands
```
get name / set name <value> # Repeater name
get tx / set tx <dbm> # TX power
get radio / set radio <freq,bw,sf,cr> # Radio params
tempradio <freq,bw,sf,cr,mins> # Temporary radio change
setperm <pubkey> <0-3> # ACL: 0=guest, 1=ro, 2=rw, 3=admin
clock / clock sync # Get/sync time
ver # Firmware version
reboot # Restart repeater
```
### CLI Response Filtering
CLI responses have `txt_type=1` (vs `txt_type=0` for normal messages). The event handler
in `event_handlers.py` skips these to prevent duplicates—the command endpoint returns
the response directly, so we don't also store/broadcast via WebSocket.
```python
# In on_contact_message()
txt_type = payload.get("txt_type", 0)
if txt_type == 1:
return # Skip CLI responses
```
### Helper Function
`prepare_repeater_connection()` handles the login dance:
1. Add contact to radio with stored path from DB (`out_path`, `out_path_len`)
2. Send login with password
3. Wait for key exchange to complete
### Contact Path Tracking
When advertisements are received, path data is extracted and stored:
- `last_path`: Hex string of routing path bytes
- `last_path_len`: Number of hops (-1=flood/unknown, 0=direct, >0=hops through repeaters)
**Shortest path selection**: When receiving echoed advertisements within 60 seconds, the shortest path is kept. This ensures we use the most efficient route when multiple paths exist.

View File

@@ -1,6 +1,7 @@
import logging
from typing import Literal
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -9,14 +10,78 @@ class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
tcp_host: str = ""
tcp_port: int = 4000
ble_address: str = ""
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
max_radio_contacts: int = 200 # Max non-repeater contacts to keep on radio for DM ACKs
disable_bots: bool = False
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
transports_set = sum(
[
bool(self.serial_port),
bool(self.tcp_host),
bool(self.ble_address),
]
)
if transports_set > 1:
raise ValueError(
"Only one transport may be configured at a time. "
"Set exactly one of MESHCORE_SERIAL_PORT, MESHCORE_TCP_HOST, or MESHCORE_BLE_ADDRESS."
)
if self.ble_address and not self.ble_pin:
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
return self
@property
def connection_type(self) -> Literal["serial", "tcp", "ble"]:
if self.tcp_host:
return "tcp"
if self.ble_address:
return "ble"
return "serial"
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(
@@ -24,3 +89,6 @@ def setup_logging() -> None:
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
# "Serial Connection started" when the port is contended).
logging.getLogger("meshcore").addFilter(_RepeatSquelch())

View File

@@ -15,12 +15,14 @@ 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,
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 (
@@ -42,7 +44,14 @@ CREATE TABLE IF NOT EXISTS messages (
signature TEXT,
outgoing INTEGER DEFAULT 0,
acked INTEGER DEFAULT 0,
UNIQUE(type, conversation_key, text, sender_timestamp)
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.
-- 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 (
@@ -50,15 +59,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);
"""
@@ -72,6 +109,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")

View File

@@ -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
@@ -393,21 +343,6 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
)
def try_parse_advertisement(raw_packet: bytes) -> ParsedAdvertisement | None:
"""
Try to parse a raw packet as an advertisement.
Returns parsed advertisement if successful, None otherwise.
"""
packet_info = parse_packet(raw_packet)
if packet_info is None:
return None
if packet_info.payload_type != PayloadType.ADVERT:
return None
return parse_advertisement(packet_info.payload)
# =============================================================================
# Direct Message (TEXT_MESSAGE) Decryption
# =============================================================================
@@ -545,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

View File

@@ -10,6 +10,8 @@ def require_connected():
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

View File

@@ -4,9 +4,14 @@ from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import Contact
from app.models import CONTACT_TYPE_REPEATER, Contact, Message, MessagePath
from app.packet_processor import process_raw_packet
from app.repository import ContactRepository, MessageRepository
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactNameHistoryRepository,
ContactRepository,
MessageRepository,
)
from app.websocket import broadcast_event
if TYPE_CHECKING:
@@ -34,7 +39,7 @@ def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> No
)
def _cleanup_expired_acks() -> None:
def cleanup_expired_acks() -> None:
"""Remove expired pending ACKs."""
now = time.time()
expired = []
@@ -47,76 +52,113 @@ def _cleanup_expired_acks() -> None:
async def on_contact_message(event: "Event") -> None:
"""Handle incoming direct messages.
"""Handle incoming direct messages from MeshCore library.
Direct messages are decrypted by MeshCore library using ECDH key exchange.
The packet processor cannot decrypt these without the node's private key.
NOTE: DMs are primarily handled by the packet processor via RX_LOG_DATA,
which decrypts using our exported private key. This handler exists as a
fallback for cases where:
1. The private key couldn't be exported (firmware without ENABLE_PRIVATE_KEY_EXPORT)
2. The packet processor couldn't match the sender to a known contact
The packet processor handles: decryption, storage, broadcast, bot trigger.
This handler only stores if the packet processor didn't already handle it
(detected via INSERT OR IGNORE returning None for duplicates).
"""
payload = event.payload
# Skip CLI command responses (txt_type=1) - these are handled by the command endpoint
# and should not be stored in the database or broadcast via WebSocket
txt_type = payload.get("txt_type", 0)
if txt_type == 1:
logger.debug("Skipping CLI response from %s (txt_type=1)", payload.get("pubkey_prefix"))
return
logger.debug("Received direct message from %s", payload.get("pubkey_prefix"))
# Get full public key if available, otherwise use prefix
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
received_at = int(time.time())
# Look up full public key from contact database if we only have prefix
if len(sender_pubkey) < 64:
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
if contact:
sender_pubkey = contact.public_key
# Look up contact from database - use prefix lookup only if needed
# (get_by_key_or_prefix does exact match first, then prefix fallback)
try:
contact = await ContactRepository.get_by_key_or_prefix(sender_pubkey)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
sender_pubkey,
)
contact = None
if contact:
sender_pubkey = contact.public_key.lower()
# Promote any prefix-stored messages to this full key
await MessageRepository.claim_prefix_messages(sender_pubkey)
# Skip messages from repeaters - they only send CLI responses, not chat messages.
# CLI responses are handled by the command endpoint and txt_type filter above.
if contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (not stored in chat history)",
sender_pubkey[:12],
)
return
# Try to create message - INSERT OR IGNORE handles duplicates atomically
# If the packet processor already stored this message, this returns None
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")
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=payload.get("text", ""),
conversation_key=sender_pubkey,
sender_timestamp=payload.get("sender_timestamp"),
sender_timestamp=sender_timestamp,
received_at=received_at,
path=payload.get("path"),
txt_type=payload.get("txt_type", 0),
path=path,
path_len=path_len,
txt_type=txt_type,
signature=payload.get("signature"),
sender_key=sender_pubkey,
sender_name=sender_name,
)
if msg_id is None:
# Duplicate message (same content from same sender) - skip broadcast
logger.debug("Duplicate direct message from %s ignored", sender_pubkey[:12])
# 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
# Build paths array for broadcast
# Use "is not None" to include empty string (direct/0-hop messages)
path = payload.get("path")
paths = [{"path": path or "", "received_at": received_at}] if path is not None else None
# If we get here, the packet processor didn't handle this message
# (likely because private key export is not available)
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
# Broadcast only genuinely new messages
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": payload.get("txt_type", 0),
"signature": payload.get("signature"),
"outgoing": False,
"acked": 0,
},
# Build paths array for broadcast
paths = (
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
if path is not None
else None
)
# Update contact last_seen and last_contacted
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
# Broadcast the new message
broadcast_event(
"message",
Message(
id=msg_id,
type="PRIV",
conversation_key=sender_pubkey,
text=payload.get("text", ""),
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=paths,
txt_type=txt_type,
signature=payload.get("signature"),
sender_key=sender_pubkey,
sender_name=sender_name,
).model_dump(),
)
# Update contact last_contacted (contact was already fetched above)
if contact:
await ContactRepository.update_last_contacted(contact.public_key, received_at)
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
async def on_rx_log_data(event: "Event") -> None:
@@ -145,15 +187,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:
@@ -172,12 +266,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),
**Contact.from_radio_dict(public_key.lower(), payload, on_radio=True),
"last_seen": int(time.time()),
}
await ContactRepository.upsert(contact_data)
broadcast_event("contact", contact_data)
# Record name history if contact has a name
adv_name = payload.get("adv_name")
if adv_name:
await ContactNameHistoryRepository.record_name(
public_key.lower(), adv_name, int(time.time())
)
backfilled = await MessageRepository.backfill_channel_sender_key(public_key, adv_name)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
adv_name,
)
# Read back from DB so the broadcast includes all fields (last_contacted,
# last_read_at, etc.) matching the REST Contact shape exactly.
db_contact = await ContactRepository.get_by_key(public_key)
broadcast_event("contact", (db_contact.model_dump() if db_contact else contact_data))
async def on_ack(event: "Event") -> None:
@@ -191,13 +302,16 @@ 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)
logger.info("ACK received for message %d", message_id)
ack_count = await MessageRepository.increment_ack_count(message_id)
# DM ACKs don't carry path data, so paths is intentionally omitted.
# The frontend's mergePendingAck handles the missing field correctly,
# preserving any previously known paths.
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
else:
logger.debug("ACK code %s does not match any pending messages", ack_code)

286
app/fanout/AGENTS_fanout.md Normal file
View 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
View 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
View 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
View 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

142
app/fanout/bot.py Normal file
View File

@@ -0,0 +1,142 @@
"""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"
# Extract bot parameters from broadcast data
if is_dm:
conversation_key = data.get("conversation_key", "")
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:
conversation_key = data.get("conversation_key", "")
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 as e:
logger.warning("Bot '%s' execution error: %s", self.name, e)
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"

259
app/fanout/bot_exec.py Normal file
View File

@@ -0,0 +1,259 @@
"""
Bot execution module for automatic message responses.
This module provides functionality for executing user-defined Python code
in response to incoming messages. The user's code can process message data
and optionally return a response string or a list of strings.
SECURITY WARNING: This executes arbitrary Python code provided by the user.
It should only be enabled on trusted systems where the user understands
the security implications.
"""
import asyncio
import inspect
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import HTTPException
logger = logging.getLogger(__name__)
# Limit concurrent bot executions to prevent resource exhaustion
_bot_semaphore = asyncio.Semaphore(100)
# Dedicated thread pool for bot execution (separate from default executor)
_bot_executor = ThreadPoolExecutor(max_workers=100, thread_name_prefix="bot_")
# Timeout for bot code execution (seconds)
BOT_EXECUTION_TIMEOUT = 10
# Minimum spacing between bot message sends (seconds)
# This ensures repeaters have time to return to listening mode
BOT_MESSAGE_SPACING = 2.0
# Global state for rate limiting bot sends
_bot_send_lock = asyncio.Lock()
_last_bot_send_time: float = 0.0
def execute_bot_code(
code: str,
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
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, 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)
sender_key: 64-char hex public key of sender for DMs, None for channel messages
message_text: The message content
is_dm: True for direct messages, False for channel messages
channel_key: 32-char hex channel key for channel messages, None for DMs
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.
Note: This executes arbitrary code. Only use with trusted input.
"""
if not code or not code.strip():
return None
# Build execution namespace with allowed imports
namespace: dict[str, Any] = {
"__builtins__": __builtins__,
}
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)
return None
# Check if bot function was defined
if "bot" not in namespace or not callable(namespace["bot"]):
logger.debug("Bot code does not define a callable 'bot' function")
return None
bot_func = namespace["bot"]
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
# or uses the legacy 8-param signature, for backward compatibility.
# Three cases: explicit is_outgoing param or 9+ params (positional),
# **kwargs (pass as keyword), or legacy 8-param (omit).
call_style = "legacy" # "positional", "keyword", or "legacy"
try:
sig = inspect.signature(bot_func)
params = sig.parameters
non_variadic = [
p
for p in params.values()
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
]
if "is_outgoing" in params or len(non_variadic) >= 9:
call_style = "positional"
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
call_style = "keyword"
except (ValueError, TypeError):
pass
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:
return None
if isinstance(result, str):
return result if result.strip() else None
if isinstance(result, list):
# Filter to non-empty strings only
valid_messages = [msg for msg in result if isinstance(msg, str) and msg.strip()]
return valid_messages if valid_messages else None
logger.debug("Bot function returned unsupported type: %s", type(result))
return None
except Exception as e:
logger.warning("Bot function execution failed: %s", e)
return None
async def process_bot_response(
response: str | list[str],
is_dm: bool,
sender_key: str,
channel_key: str | None,
) -> None:
"""
Send the bot's response message(s) using the existing message sending endpoints.
For DMs, sends a direct message back to the sender.
For channel messages, sends to the same channel.
Bot messages are rate-limited to ensure at least BOT_MESSAGE_SPACING seconds
between sends, giving repeaters time to return to listening mode.
Args:
response: The response text to send, or a list of messages to send in order
is_dm: Whether the original message was a DM
sender_key: Public key of the original sender (for DM replies)
channel_key: Channel key for channel message replies
"""
# Normalize to list for uniform processing
messages = [response] if isinstance(response, str) else response
for message_text in messages:
await _send_single_bot_message(message_text, is_dm, sender_key, channel_key)
async def _send_single_bot_message(
message_text: str,
is_dm: bool,
sender_key: str,
channel_key: str | None,
) -> None:
"""
Send a single bot message with rate limiting.
Args:
message_text: The message text to send
is_dm: Whether the original message was a DM
sender_key: Public key of the original sender (for DM replies)
channel_key: Channel key for channel message replies
"""
global _last_bot_send_time
from app.models import SendChannelMessageRequest, SendDirectMessageRequest
from app.routers.messages import send_channel_message, send_direct_message
# Serialize bot sends and enforce minimum spacing
async with _bot_send_lock:
# Calculate how long since last bot send
now = time.monotonic()
time_since_last = now - _last_bot_send_time
if _last_bot_send_time > 0 and time_since_last < BOT_MESSAGE_SPACING:
wait_time = BOT_MESSAGE_SPACING - time_since_last
logger.debug("Rate limiting bot send, waiting %.2fs", wait_time)
await asyncio.sleep(wait_time)
try:
if is_dm:
logger.info("Bot sending DM reply to %s", sender_key[:12])
request = SendDirectMessageRequest(destination=sender_key, text=message_text)
await send_direct_message(request)
elif channel_key:
logger.info("Bot sending channel reply to %s", channel_key[:8])
request = SendChannelMessageRequest(channel_key=channel_key, text=message_text)
await send_channel_message(request)
else:
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)
return # Don't update timestamp on failure
except Exception as e:
logger.error("Bot failed to send response: %s", e)
return # Don't update timestamp on failure
# Update last send time after successful send
_last_bot_send_time = time.monotonic()

View File

@@ -0,0 +1,568 @@
"""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.radio import 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.radio import 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, 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, 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.radio import 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
View 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
View 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"

232
app/fanout/mqtt_base.py Normal file
View File

@@ -0,0 +1,232 @@
"""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.radio import 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)
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,
)
try:
await asyncio.sleep(backoff)
except asyncio.CancelledError:
return
backoff = min(backoff * 2, self._backoff_max)

View 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.radio import 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)

View 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
View 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"

135
app/frontend_static.py Normal file
View File

@@ -0,0 +1,135 @@
import logging
from pathlib import Path
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.
Returns True when routes are registered, False when frontend files are
missing/incomplete. Missing frontend files are logged but are not fatal.
"""
frontend_dir = frontend_dir.resolve()
index_file = frontend_dir / "index.html"
assets_dir = frontend_dir / "assets"
if not frontend_dir.exists():
logger.error(
"Frontend build directory not found at %s. "
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
frontend_dir,
)
return False
if not frontend_dir.is_dir():
logger.error(
"Frontend build path is not a directory: %s. "
"API will continue without frontend routes.",
frontend_dir,
)
return False
if not index_file.exists():
logger.error(
"Frontend index file not found at %s. "
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
index_file,
)
return False
if assets_dir.exists() and assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
else:
logger.warning(
"Frontend assets directory missing at %s; /assets files will not be served",
assets_dir,
)
@app.get("/")
async def serve_index():
"""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."""
file_path = (frontend_dir / path).resolve()
try:
file_path.relative_to(frontend_dir)
except ValueError:
raise HTTPException(status_code=404, detail="Not found") from None
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
return FileResponse(index_file)
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"
},
)

View File

@@ -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
@@ -64,14 +71,6 @@ def has_private_key() -> bool:
return _private_key is not None
def clear_private_key() -> None:
"""Clear the stored private key from memory."""
global _private_key, _public_key
_private_key = None
_public_key = None
logger.info("Private key cleared from keystore")
async def export_and_store_private_key(mc: "MeshCore") -> bool:
"""Export private key from the radio and store it in the keystore.
@@ -99,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

View File

@@ -1,34 +1,33 @@
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 FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
from app.config import setup_logging
from app.database import db
from app.event_handlers import register_event_handlers
from app.radio import radio_manager
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
from app.radio import RadioDisconnectedError, radio_manager
from app.radio_sync import (
drain_pending_messages,
start_message_polling,
start_periodic_sync,
stop_message_polling,
stop_periodic_advert,
stop_periodic_sync,
sync_and_offload_all,
sync_radio_time,
)
from app.routers import (
channels,
contacts,
fanout,
health,
messages,
packets,
radio,
read_state,
repeaters,
settings,
statistics,
ws,
)
@@ -36,60 +35,62 @@ 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(broadcast_on_success=False)
if connected:
await radio_manager.post_connect_setup()
from app.websocket import broadcast_health
broadcast_health(True, radio_manager.connection_info)
logger.info("Connected to radio")
else:
logger.warning("Failed to connect to radio on startup")
except Exception as e:
logger.warning("Failed to connect to radio on startup: %s", e)
@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")
if radio_manager.meshcore:
register_event_handlers(radio_manager.meshcore)
# 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
# Export and store private key for server-side DM decryption
from app.keystore import export_and_store_private_key
await export_and_store_private_key(radio_manager.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
start_periodic_sync()
# Send advertisement to announce our presence
logger.info("Sending startup advertisement...")
advert_result = await radio_manager.meshcore.commands.send_advert(flood=True)
logger.info("Advertisement sent: %s", advert_result.type)
await radio_manager.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 for unreliable push events
start_message_polling()
except Exception as e:
logger.warning("Failed to connect to radio on startup: %s", e)
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 as e:
logger.warning("Failed to start fanout modules: %s", e)
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()
await stop_periodic_sync()
if radio_manager.meshcore:
await radio_manager.meshcore.stop_auto_message_fetching()
@@ -97,10 +98,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="0.1.0",
version=_get_version(),
lifespan=lifespan,
)
@@ -112,35 +125,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"
if FRONTEND_DIR.exists():
# Serve static assets (JS, CSS, etc.)
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
# Serve other static files from frontend/dist (like wordlist)
@app.get("/{path:path}")
async def serve_frontend(path: str):
"""Serve frontend files, falling back to index.html for SPA routing."""
file_path = FRONTEND_DIR / path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Fall back to index.html for SPA routing
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/")
async def serve_index():
"""Serve the frontend index.html."""
return FileResponse(FRONTEND_DIR / "index.html")
if not register_frontend_static_routes(app, FRONTEND_DIR):
register_frontend_missing_fallback(app)

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,11 @@ from pydantic import BaseModel, Field
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
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
@@ -17,6 +18,7 @@ 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 to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands.
@@ -31,9 +33,10 @@ class Contact(BaseModel):
"flags": self.flags,
"out_path": self.last_path or "",
"out_path_len": self.last_path_len,
"adv_lat": self.lat or 0.0,
"adv_lon": self.lon or 0.0,
"last_advert": self.last_advert or 0,
"out_path_hash_mode": self.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,
}
@staticmethod
@@ -50,6 +53,10 @@ class Contact(BaseModel):
"flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1),
"out_path_hash_mode": radio_data.get(
"out_path_hash_mode",
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
),
"lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"),
@@ -72,6 +79,71 @@ class CreateContactRequest(BaseModel):
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
@@ -80,11 +152,43 @@ class Channel(BaseModel):
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,22 +203,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 RawPacket(BaseModel):
"""Raw packet as stored in the database."""
id: int
timestamp: int
data: str = Field(description="Hex-encoded packet data")
message_id: int | None = None
@property
def decrypted(self) -> bool:
"""A packet is decrypted iff it has a linked message_id."""
return self.message_id is not None
class MessagesAroundResponse(BaseModel):
messages: list[Message]
has_older: bool
has_newer: bool
class RawPacketDecryptedInfo(BaseModel):
@@ -122,6 +221,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):
@@ -132,6 +233,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)")
@@ -146,19 +252,96 @@ class SendMessageRequest(BaseModel):
class SendDirectMessageRequest(SendMessageRequest):
destination: str = Field(description="Public key or prefix of recipient")
destination: str = Field(
description="Recipient public key (64-char hex preferred; prefix must resolve uniquely)"
)
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."""
@@ -179,33 +362,32 @@ 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")
class TraceResponse(BaseModel):
"""Result of a direct (zero-hop) trace to a contact."""
remote_snr: float | None = Field(
default=None, description="SNR at which the target heard us (dB)"
)
local_snr: float | None = Field(
default=None, description="SNR at which we heard the target on the bounce-back (dB)"
)
path_len: int = Field(description="Number of hops in the trace path")
class CommandRequest(BaseModel):
"""Request to send a CLI command to a repeater."""
@@ -229,12 +411,29 @@ class Favorite(BaseModel):
id: str = Field(description="Channel key or contact public key")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
counts: dict[str, int] = Field(
default_factory=dict, description="Map of stateKey -> unread count"
)
mentions: dict[str, bool] = Field(
default_factory=dict, description="Map of stateKey -> has mention"
)
last_message_times: dict[str, int] = Field(
default_factory=dict, description="Map of stateKey -> last message timestamp"
)
class AppSettings(BaseModel):
"""Application settings stored in the database."""
max_radio_contacts: int = Field(
default=200,
description="Maximum non-repeater contacts to keep on radio for DM ACKs",
description=(
"Maximum contacts to keep on radio for DM ACKs "
"(favorite contacts first, then recent non-repeaters)"
),
)
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
@@ -255,3 +454,74 @@ class AppSettings(BaseModel):
default=False,
description="Whether preferences have been migrated from localStorage",
)
advert_interval: int = Field(
default=0,
description="Periodic advertisement interval in seconds (0 = disabled)",
)
last_advert_time: int = Field(
default=0,
description="Unix timestamp of last advertisement sent (0 = never)",
)
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

View File

@@ -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,9 +28,17 @@ 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_TYPE_REPEATER,
Message,
MessagePath,
RawPacketBroadcast,
RawPacketDecryptedInfo,
)
from app.repository import (
ChannelRepository,
ContactAdvertPathRepository,
ContactNameHistoryRepository,
ContactRepository,
MessageRepository,
RawPacketRepository,
@@ -38,6 +47,79 @@ from app.websocket import broadcast_error, broadcast_event
logger = logging.getLogger(__name__)
_raw_observation_counter = count(1)
async def _handle_duplicate_message(
packet_id: int,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int,
path: str | None,
received: int,
path_len: int | None = None,
) -> 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, path_len)
else:
# Get current paths for broadcast
paths = existing_msg.paths or []
# Increment ack count for outgoing messages (echo confirmation)
if existing_msg.outgoing:
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
else:
ack_count = existing_msg.acked
# Only broadcast when something actually changed:
# - outgoing: ack count was incremented
# - path provided: a new path entry was appended
# The path=None case happens for direct-delivery DMs (0-hop, no routing bytes).
# A non-outgoing duplicate with no new path changes nothing in the DB, so skip.
if existing_msg.outgoing or path is not None:
broadcast_event(
"message_acked",
{
"message_id": existing_msg.id,
"ack_count": ack_count,
"paths": [p.model_dump() for p in paths] if paths else [],
},
)
# Mark this packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
async def create_message_from_decrypted(
packet_id: int,
@@ -47,6 +129,9 @@ 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,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted channel packet content.
@@ -56,11 +141,13 @@ async def create_message_from_decrypted(
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 (None for historical decryption)
path: Hex-encoded routing path
realtime: If False, skip fanout dispatch (used for historical decryption)
Returns the message ID if created, None if duplicate.
"""
@@ -72,6 +159,13 @@ async def create_message_from_decrypted(
# Normalize channel key to uppercase for consistency
channel_key_normalized = channel_key.upper()
# Resolve sender_key: look up contact by exact name match
resolved_sender_key: str | None = None
if sender:
candidates = await ContactRepository.get_by_name(sender)
if len(candidates) == 1:
resolved_sender_key = candidates[0].public_key
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="CHAN",
@@ -80,6 +174,9 @@ async def create_message_from_decrypted(
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:
@@ -87,52 +184,9 @@ async def create_message_from_decrypted(
# 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.
existing_msg = await MessageRepository.get_by_content(
msg_type="CHAN",
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
await _handle_duplicate_message(
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received, path_len
)
if not existing_msg:
logger.warning(
"Duplicate message for channel %s but couldn't find existing",
channel_key_normalized[:8],
)
return None
logger.debug(
"Duplicate message for channel %s (msg_id=%d, outgoing=%s) - adding path",
channel_key_normalized[:8],
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)
return None
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
@@ -142,24 +196,28 @@ async def create_message_from_decrypted(
# 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
paths = (
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients
# Broadcast new message to connected clients (and fanout modules when realtime)
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,
},
Message(
id=msg_id,
type="CHAN",
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
received_at=received,
paths=paths,
sender_name=sender,
sender_key=resolved_sender_key,
channel_name=channel_name,
).model_dump(),
realtime=realtime,
)
return msg_id
@@ -172,7 +230,9 @@ 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,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted direct message packet content.
@@ -185,16 +245,31 @@ async def create_dm_message_from_decrypted(
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 (None for historical decryption)
path: Hex-encoded routing path
outgoing: Whether this is an outgoing message (we sent it)
realtime: If False, skip fanout dispatch (used 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()
# Resolve sender name for incoming messages (used for name-based blocking)
sender_name = contact.name if contact and not outgoing else None
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="PRIV",
@@ -203,56 +278,24 @@ async def create_dm_message_from_decrypted(
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:
# Duplicate message detected
existing_msg = await MessageRepository.get_by_content(
msg_type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
await _handle_duplicate_message(
packet_id,
"PRIV",
conversation_key,
decrypted.message,
decrypted.timestamp,
path,
received,
path_len,
)
if not existing_msg:
logger.warning(
"Duplicate DM for contact %s but couldn't find existing",
conversation_key[:12],
)
return None
logger.debug(
"Duplicate DM for contact %s (msg_id=%d, outgoing=%s) - adding path",
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:
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)
return None
logger.info(
@@ -266,24 +309,29 @@ async def create_dm_message_from_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
paths = (
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients
# Broadcast new message to connected clients (and fanout modules when realtime)
sender_name = contact.name if contact and not outgoing else None
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,
},
Message(
id=msg_id,
type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
paths=paths,
outgoing=outgoing,
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
).model_dump(),
realtime=realtime,
)
# Update contact's last_contacted timestamp (for sorting)
@@ -315,7 +363,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:
# Don't pass our_public_key - we want to decrypt both incoming AND outgoing 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,
@@ -324,14 +379,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,
@@ -340,7 +406,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,
realtime=False, # Historical decryption should not trigger fanout
)
if msg_id is not None:
@@ -428,6 +496,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()
@@ -437,6 +506,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(
@@ -469,8 +545,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:
@@ -483,6 +561,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,
@@ -492,6 +571,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,
@@ -535,11 +616,13 @@ async def _process_group_text(
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel.key,
channel_name=channel.name,
sender=decrypted.sender,
message_text=decrypted.message,
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 {
@@ -547,6 +630,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
@@ -581,7 +665,7 @@ async def _process_advertisement(
new_path_hex = packet_info.path.hex() if packet_info.path else ""
# Try to find existing contact
existing = await ContactRepository.get_by_key(advert.public_key)
existing = await ContactRepository.get_by_key(advert.public_key.lower())
# Determine which path to use: keep shorter path if heard recently (within 60s)
# This handles advertisement echoes through different routes
@@ -607,9 +691,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)",
@@ -627,8 +713,25 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
# 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,
)
# Record name history
if advert.name:
await ContactNameHistoryRepository.record_name(
public_key=advert.public_key.lower(),
name=advert.name,
timestamp=timestamp,
)
contact_data = {
"public_key": advert.public_key,
"public_key": advert.public_key.lower(),
"name": advert.name,
"type": contact_type,
"lat": advert.lat,
@@ -637,27 +740,36 @@ async def _process_advertisement(
"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_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],
)
if advert.name:
backfilled = await MessageRepository.backfill_channel_sender_key(
advert.public_key, advert.name
)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
advert.name,
)
# Broadcast contact update to connected clients
broadcast_event(
"contact",
{
"public_key": advert.public_key,
"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,
},
)
# 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_data)
# 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
@@ -666,7 +778,7 @@ async def _process_advertisement(
settings = await AppSettingsRepository.get()
if settings.auto_decrypt_dm_on_advert:
await start_historical_dm_decryption(None, advert.public_key, advert.name)
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
@@ -738,9 +850,8 @@ async def _process_direct_message(
# For outgoing: match dest_hash (recipient's first byte)
match_hash = dest_hash if is_outgoing else src_hash
# Get all contacts and filter by first byte of public key
contacts = await ContactRepository.get_all(limit=1000)
candidate_contacts = [c for c in contacts if c.public_key.lower().startswith(match_hash)]
# Get contacts matching the first byte of public key via targeted SQL query
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(match_hash)
if not candidate_contacts:
logger.debug(
@@ -781,7 +892,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,
)
@@ -790,6 +902,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

150
app/path_utils.py Normal file
View File

@@ -0,0 +1,150 @@
"""
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 → 063 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

View File

@@ -2,6 +2,7 @@ import asyncio
import glob
import logging
import platform
from contextlib import asynccontextmanager, nullcontext
from pathlib import Path
from meshcore import MeshCore
@@ -11,6 +12,18 @@ from app.config import settings
logger = logging.getLogger(__name__)
class RadioOperationError(RuntimeError):
"""Base class for shared radio operation lock errors."""
class RadioOperationBusyError(RadioOperationError):
"""Raised when a non-blocking radio operation cannot acquire the lock."""
class RadioDisconnectedError(RadioOperationError):
"""Raised when the radio disconnects between pre-check and lock acquisition."""
def detect_serial_devices() -> list[str]:
"""Detect available serial devices based on platform."""
devices: list[str] = []
@@ -55,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(
@@ -65,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)
@@ -76,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:
@@ -102,18 +120,250 @@ class RadioManager:
def __init__(self):
self._meshcore: MeshCore | None = None
self._port: str | None = None
self._connection_info: str | None = None
self._reconnect_task: asyncio.Task | None = None
self._last_connected: bool = False
self._reconnect_lock: asyncio.Lock | None = None
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,
name: str,
*,
blocking: bool,
) -> None:
"""Acquire the shared radio operation lock."""
if self._operation_lock is None:
self._operation_lock = asyncio.Lock()
if not blocking:
if self._operation_lock.locked():
raise RadioOperationBusyError(f"Radio is busy (operation: {name})")
await self._operation_lock.acquire()
else:
await self._operation_lock.acquire()
logger.debug("Acquired radio operation lock (%s)", name)
def _release_operation_lock(self, name: str) -> None:
"""Release the shared radio operation lock."""
if self._operation_lock and self._operation_lock.locked():
self._operation_lock.release()
logger.debug("Released radio operation lock (%s)", name)
else:
logger.error("Attempted to release unlocked radio operation lock (%s)", name)
@asynccontextmanager
async def radio_operation(
self,
name: str,
*,
pause_polling: bool = False,
suspend_auto_fetch: bool = False,
blocking: bool = True,
):
"""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.
Raises:
RadioDisconnectedError: If the radio disconnected before the lock
was acquired (``_meshcore`` is ``None``).
"""
await self._acquire_operation_lock(name, blocking=blocking)
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()
auto_fetch_paused = False
try:
async with poll_context:
if suspend_auto_fetch:
await mc.stop_auto_message_fetching()
auto_fetch_paused = True
yield mc
finally:
try:
if auto_fetch_paused:
try:
await mc.start_auto_message_fetching()
except Exception as e:
logger.warning("Failed to restart auto message fetching (%s): %s", name, e)
finally:
self._release_operation_lock(name)
async def post_connect_setup(self) -> None:
"""Full post-connection setup: handlers, key export, sync, advertisements, polling.
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
self._setup_complete = False
mc = self._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 self._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.repository import AppSettingsRepository
app_settings = await AppSettingsRepository.get()
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
self.path_hash_mode = 0
self.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:
self.path_hash_mode = device_query.payload["path_hash_mode"]
self.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:
self.path_hash_mode = raw[81]
self.path_hash_mode_supported = True
logger.warning(
"path_hash_mode=%d extracted from raw frame "
"(stale .pyc? try: rm %s)",
self.path_hash_mode,
getattr(
__import__("meshcore.reader", fromlist=["reader"]),
"__cached__",
"meshcore __pycache__/reader.*.pyc",
),
)
if self.path_hash_mode_supported:
logger.info("Path hash mode: %d (supported)", self.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:
self._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()
self._setup_complete = True
finally:
self._setup_in_progress = False
logger.info("Post-connect setup complete")
@property
def meshcore(self) -> MeshCore | None:
return self._meshcore
@property
def port(self) -> str | None:
return self._port
def connection_info(self) -> str | None:
return self._connection_info
@property
def is_connected(self) -> bool:
@@ -123,11 +373,29 @@ class RadioManager:
def is_reconnecting(self) -> bool:
return self._reconnect_lock is not None and self._reconnect_lock.locked()
@property
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 over serial."""
"""Connect to the radio using the configured transport."""
if self._meshcore is not None:
await self.disconnect()
connection_type = settings.connection_type
if connection_type == "tcp":
await self._connect_tcp()
elif connection_type == "ble":
await self._connect_ble()
else:
await self._connect_serial()
async def _connect_serial(self) -> None:
"""Connect to the radio over serial."""
port = settings.serial_port
# Auto-detect if no port specified
@@ -148,19 +416,57 @@ class RadioManager:
auto_reconnect=True,
max_reconnect_attempts=10,
)
self._port = port
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:
"""Connect to the radio over TCP."""
host = settings.tcp_host
port = settings.tcp_port
logger.debug("Connecting to radio at %s:%d (TCP)", host, port)
self._meshcore = await MeshCore.create_tcp(
host=host,
port=port,
auto_reconnect=True,
max_reconnect_attempts=10,
)
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:
"""Connect to the radio over BLE."""
address = settings.ble_address
pin = settings.ble_pin
logger.debug("Connecting to radio at %s (BLE)", address)
self._meshcore = await MeshCore.create_ble(
address=address,
pin=pin,
auto_reconnect=True,
max_reconnect_attempts=15,
)
self._connection_info = f"BLE: {address}"
self._last_connected = True
self._setup_complete = False
logger.debug("BLE connection established")
async def disconnect(self) -> None:
"""Disconnect from the radio."""
if self._meshcore is not None:
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) -> bool:
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
"""Attempt to reconnect to the radio.
Returns True if reconnection was successful, False otherwise.
@@ -172,12 +478,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:
@@ -193,8 +500,9 @@ class RadioManager:
await self.connect()
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._port)
broadcast_health(True, self._port)
logger.info("Radio reconnected successfully at %s", self._connection_info)
if broadcast_on_success:
broadcast_health(True, self._connection_info)
return True
else:
logger.warning("Reconnection failed: not connected after connect()")
@@ -213,9 +521,13 @@ class RadioManager:
async def monitor_loop():
from app.websocket import broadcast_health
CHECK_INTERVAL_SECONDS = 5
UNRESPONSIVE_THRESHOLD = 3
consecutive_setup_failures = 0
while True:
try:
await asyncio.sleep(5) # Check every 5 seconds
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
current_connected = self.is_connected
@@ -223,34 +535,55 @@ class RadioManager:
if self._last_connected and not current_connected:
# Connection lost
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, self._port)
broadcast_health(False, self._connection_info)
self._last_connected = False
consecutive_setup_failures = 0
# Attempt reconnection
await asyncio.sleep(3) # Wait a bit before trying
if await self.reconnect():
# Re-register event handlers after successful reconnect
from app.event_handlers import register_event_handlers
from app.keystore import export_and_store_private_key
if self._meshcore:
register_event_handlers(self._meshcore)
await export_and_store_private_key(self._meshcore)
await self._meshcore.start_auto_message_fetching()
logger.info("Event handlers re-registered after auto-reconnect")
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
consecutive_setup_failures = 0
elif not self._last_connected and current_connected:
# Connection restored (might have reconnected automatically)
# Connection restored (might have reconnected automatically).
# Always run setup before reporting healthy.
logger.info("Radio connection restored")
broadcast_health(True, self._port)
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
consecutive_setup_failures = 0
elif current_connected and not self._setup_complete:
# Transport connected but setup incomplete — retry
logger.info("Retrying post-connect setup...")
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
consecutive_setup_failures = 0
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)
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)
# After the threshold, silently retry (avoid log spam)
self._reconnect_task = asyncio.create_task(monitor_loop())
logger.info("Radio connection monitor started")

View File

@@ -14,20 +14,65 @@ import logging
import time
from contextlib import asynccontextmanager
from meshcore import EventType
from meshcore import EventType, MeshCore
from app.config import settings
from app.event_handlers import cleanup_expired_acks
from app.models import Contact
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository
from app.radio import RadioOperationBusyError, radio_manager
from app.repository import (
AmbiguousPublicKeyPrefixError,
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
)
logger = logging.getLogger(__name__)
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 (10s gives DM ACKs plenty of time to arrive)
MESSAGE_POLL_INTERVAL = 10
# Periodic advertisement task handle
_advert_task: asyncio.Task | None = None
# Default check interval when periodic advertising is disabled (seconds)
# 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
@@ -59,16 +104,11 @@ _sync_task: asyncio.Task | None = None
SYNC_INTERVAL = 300
async def sync_and_offload_contacts() -> dict:
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
@@ -77,7 +117,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 {}
@@ -89,6 +138,24 @@ async def sync_and_offload_contacts() -> dict:
await ContactRepository.upsert(
Contact.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],
)
adv_name = contact_data.get("adv_name")
if adv_name:
backfilled = await MessageRepository.backfill_channel_sender_key(
public_key, adv_name
)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
adv_name,
)
synced += 1
# Remove from radio
@@ -96,6 +163,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
@@ -112,16 +203,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
@@ -133,29 +219,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:
@@ -202,33 +274,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 and recent contacts back onto the radio immediately
# so favorited contacts don't stay in the on_radio=False limbo until the
# next advertisement arrives. 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
@@ -250,13 +325,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).
@@ -265,10 +340,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:
@@ -283,12 +354,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
@@ -299,13 +370,30 @@ async def _message_poll_loop():
try:
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
# Clean up expired pending ACKs every poll cycle so they don't
# accumulate when no ACKs arrive (e.g. all recipients out of range).
cleanup_expired_acks()
if radio_manager.is_connected and not is_polling_paused():
await poll_for_messages()
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:
logger.warning(
"Poll loop caught %d message(s) missed by auto-fetch",
count,
)
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():
@@ -329,16 +417,122 @@ async def stop_message_polling():
logger.info("Stopped periodic message polling")
async def sync_radio_time() -> 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).
"""
# Check if enough time has elapsed (unless forced)
if not force:
settings = await AppSettingsRepository.get()
interval = settings.advert_interval
last_time = settings.last_advert_time
now = int(time.time())
# If interval is 0, advertising is disabled
if interval <= 0:
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:
remaining = interval - elapsed
logger.debug(
"Advertisement throttled: %d seconds remaining (interval=%d, elapsed=%d)",
remaining,
interval,
elapsed,
)
return False
try:
result = await mc.commands.send_advert(flood=True)
if result.type == EventType.OK:
# Update last_advert_time in database
now = int(time.time())
await AppSettingsRepository.update(last_advert_time=now)
logger.info("Advertisement sent successfully")
return True
else:
logger.warning("Failed to send advertisement: %s", result.payload)
return False
except Exception as e:
logger.warning("Error sending advertisement: %s", e)
return False
async def _periodic_advert_loop():
"""Background task that periodically checks if an advertisement should be sent.
The actual throttling logic is in send_advertisement(), which checks
last_advert_time from the database. This loop just triggers the check
periodically and sleeps between attempts.
"""
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:
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)
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
def start_periodic_advert():
"""Start the periodic advertisement background task.
The task reads interval from app_settings dynamically, so it will
adapt to configuration changes without restart.
"""
global _advert_task
if _advert_task is None or _advert_task.done():
_advert_task = asyncio.create_task(_periodic_advert_loop())
logger.info("Started periodic advertisement task (interval configured in settings)")
async def stop_periodic_advert():
"""Stop the periodic advertisement background task."""
global _advert_task
if _advert_task and not _advert_task.done():
_advert_task.cancel()
try:
await _advert_task
except asyncio.CancelledError:
pass
_advert_task = None
logger.info("Stopped periodic advertisement")
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)
@@ -354,9 +548,19 @@ async def _periodic_sync_loop():
while True:
try:
await asyncio.sleep(SYNC_INTERVAL)
logger.debug("Running periodic radio sync")
await sync_and_offload_all()
await sync_radio_time()
if not radio_manager.is_connected:
continue
try:
async with radio_manager.radio_operation(
"periodic_sync",
blocking=False,
) as mc:
logger.debug("Running periodic radio sync")
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
@@ -390,13 +594,130 @@ _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:
"""
Load recent non-repeater contacts to the radio for DM ACK support.
Core logic for loading contacts onto the radio.
This ensures the radio can auto-ACK incoming DMs from recent contacts.
Favorite contacts are prioritized first, then recent non-repeater contacts
fill remaining slots up to max_radio_contacts.
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
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
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: %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,
}
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.
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
@@ -407,61 +728,25 @@ 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
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
_last_contact_sync = now
try:
# Get recent non-repeater contacts from database
max_contacts = settings.max_radio_contacts
contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
logger.debug("Found %d recent non-repeater contacts to sync", len(contacts))
loaded = 0
already_on_radio = 0
failed = 0
for contact in 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,
}
async with radio_manager.radio_operation(
"sync_recent_contacts_to_radio",
blocking=False,
) as mc:
_last_contact_sync = now
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)

View File

@@ -1,894 +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, Channel, Contact, Favorite, Message, MessagePath, RawPacket
logger = logging.getLogger(__name__)
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"),
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") or contact.get("adv_lat"),
contact.get("lon") or 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,))
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:
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key LIKE ? LIMIT 1",
(f"{prefix}%",),
)
row = await cursor.fetchone()
return ContactRepository._row_to_contact(row) if row else None
@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 not contact:
contact = await ContactRepository.get_by_key_prefix(key_or_prefix)
return contact
@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),
)
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),
)
await db.conn.commit()
@staticmethod
async def delete(public_key: str) -> None:
await db.conn.execute(
"DELETE FROM contacts WHERE public_key = ?",
(public_key,),
)
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 or int(time.time())
await db.conn.execute(
"UPDATE contacts SET last_contacted = ?, last_seen = ? WHERE public_key = ?",
(ts, ts, public_key),
)
await db.conn.commit()
@staticmethod
async def clear_all_on_radio() -> None:
"""Clear the on_radio flag for all contacts."""
await db.conn.execute("UPDATE contacts SET on_radio = 0")
await db.conn.commit()
@staticmethod
async def set_multiple_on_radio(public_keys: list[str], on_radio: bool = True) -> None:
"""Set on_radio flag for multiple contacts."""
if not public_keys:
return
placeholders = ",".join("?" * len(public_keys))
await db.conn.execute(
f"UPDATE contacts SET on_radio = ? WHERE public_key IN ({placeholders})",
[on_radio] + public_keys,
)
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 or int(time.time())
cursor = await db.conn.execute(
"UPDATE contacts SET last_read_at = ? WHERE public_key = ?",
(ts, public_key),
)
await db.conn.commit()
return cursor.rowcount > 0
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 get_by_name(name: str) -> Channel | None:
"""Get a channel by name."""
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels WHERE name = ?",
(name,),
)
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 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 or 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
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
def _serialize_paths(paths: list[dict] | None) -> str | None:
"""Serialize paths list to JSON string."""
if not paths:
return None
return json.dumps(paths)
@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 or int(time.time())
# Get current paths
cursor = await db.conn.execute("SELECT paths FROM messages WHERE id = ?", (message_id,))
row = await cursor.fetchone()
if not row:
return []
# Parse existing paths or start with empty list
existing_paths = []
if row["paths"]:
try:
existing_paths = json.loads(row["paths"])
except json.JSONDecodeError:
existing_paths = []
# Add new path
existing_paths.append({"path": path, "received_at": ts})
# Update database
paths_json = json.dumps(existing_paths)
await db.conn.execute(
"UPDATE messages SET paths = ? WHERE id = ?",
(paths_json, message_id),
)
await db.conn.commit()
return [MessagePath(**p) for p in existing_paths]
@staticmethod
async def get_all(
limit: int = 100,
offset: int = 0,
msg_type: str | None = None,
conversation_key: str | 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:
# Support both exact match and prefix match for DMs
query += " AND conversation_key LIKE ?"
params.append(f"{conversation_key}%")
query += " ORDER BY received_at DESC LIMIT ? OFFSET ?"
params.extend([limit, 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_bulk(
conversations: list[dict],
limit_per_conversation: int = 100,
) -> dict[str, list["Message"]]:
"""Fetch messages for multiple conversations in one query per conversation.
Args:
conversations: List of {type: 'PRIV'|'CHAN', conversation_key: string}
limit_per_conversation: Max messages to return per conversation
Returns:
Dict mapping 'type:conversation_key' to list of messages
"""
result: dict[str, list[Message]] = {}
for conv in conversations:
msg_type = conv.get("type")
conv_key = conv.get("conversation_key")
if not msg_type or not conv_key:
continue
key = f"{msg_type}:{conv_key}"
cursor = await db.conn.execute(
"""
SELECT * FROM messages
WHERE type = ? AND conversation_key LIKE ?
ORDER BY received_at DESC
LIMIT ?
""",
(msg_type, f"{conv_key}%", limit_per_conversation),
)
rows = await cursor.fetchall()
result[key] = [
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
]
return result
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 or 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 get_undecrypted(limit: int = 100) -> list[RawPacket]:
"""Get undecrypted packets (those without a linked message)."""
cursor = await db.conn.execute(
"""
SELECT id, timestamp, data, message_id FROM raw_packets
WHERE message_id IS NULL
ORDER BY timestamp DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [
RawPacket(
id=row["id"],
timestamp=row["timestamp"],
data=row["data"].hex(),
message_id=row["message_id"],
)
for row in rows
]
@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, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated
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 = {}
# 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"]),
)
@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,
) -> 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 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 update_last_message_time(state_key: str, timestamp: int) -> None:
"""Update the last message time for a conversation atomically.
Only updates if the new timestamp is greater than the existing one.
Uses SQLite's json_set for atomic update to avoid race conditions.
"""
# Use COALESCE to handle NULL or missing keys, json_set for atomic update
# Only update if new timestamp > existing (or key doesn't exist)
await db.conn.execute(
"""
UPDATE app_settings
SET last_message_times = json_set(
COALESCE(last_message_times, '{}'),
'$.' || ?,
?
)
WHERE id = 1
AND (
json_extract(last_message_times, '$.' || ?) IS NULL
OR json_extract(last_message_times, '$.' || ?) < ?
)
""",
(state_key, timestamp, state_key, state_key, timestamp),
)
await db.conn.commit()
@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

View 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",
]

View File

@@ -0,0 +1,86 @@
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)
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()

442
app/repository/contacts.py Normal file
View File

@@ -0,0 +1,442 @@
import time
from typing import Any
from app.database import db
from app.models import (
Contact,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactNameHistory,
)
from app.path_utils import first_hop_hex
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:
out_path_hash_mode = contact.get("out_path_hash_mode")
if out_path_hash_mode is None:
out_path_hash_mode = -1 if contact.get("last_path_len", -1) == -1 else 0
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
out_path_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,
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.get("public_key", "").lower(),
contact.get("name"),
contact.get("type", 0),
contact.get("flags", 0),
contact.get("last_path"),
contact.get("last_path_len", -1),
out_path_hash_mode,
contact.get("last_advert"),
contact.get("lat"),
contact.get("lon"),
contact.get("last_seen", int(time.time())),
contact.get("on_radio"),
contact.get("last_contacted"),
contact.get("first_seen"),
),
)
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"],
out_path_hash_mode=row["out_path_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_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,
out_path_hash_mode: int | None = None,
) -> None:
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 = ?""",
(path, path_len, out_path_hash_mode, 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 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
View 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
View 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]

View 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
View 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,
}

View File

@@ -6,9 +6,10 @@ from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import Channel
from app.radio_sync import ensure_default_channels
from app.repository import ChannelRepository
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.radio import radio_manager
from app.radio_sync import upsert_channel_from_radio_slot
from app.repository import ChannelRepository, MessageRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -25,11 +26,27 @@ class CreateChannelRequest(BaseModel):
@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)."""
@@ -84,35 +101,22 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
@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
for idx in range(max_channels):
result = await mc.commands.get_channel(idx)
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)
if result.type == EventType.CHANNEL_INFO:
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
if key_hex is not None:
count += 1
logger.debug(
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
)
logger.info("Synced %d channels from radio", count)
return {"synced": count}
@@ -141,4 +145,9 @@ async def delete_channel(key: str) -> dict:
"""
logger.info("Deleting channel %s from database", key)
await ChannelRepository.delete(key)
from app.websocket import broadcast_event
broadcast_event("channel_deleted", {"key": key})
return {"status": "ok"}

View File

@@ -1,66 +1,62 @@
import asyncio
import logging
import random
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
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,
CreateContactRequest,
NeighborInfo,
TelemetryRequest,
TelemetryResponse,
NearestRepeater,
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
from app.radio import radio_manager
from app.radio_sync import pause_polling
from app.repository import ContactRepository
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactAdvertPathRepository,
ContactNameHistoryRepository,
ContactRepository,
MessageRepository,
)
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])
return (
f"Ambiguous contact key prefix '{err.prefix}'. "
f"Use a full 64-character public key. Matching contacts: {sample}"
)
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 _resolve_contact_or_404(
public_key: str, not_found_detail: str = "Contact not found"
) -> Contact:
try:
contact = await ContactRepository.get_by_key_or_prefix(public_key)
except AmbiguousPublicKeyPrefixError as err:
raise HTTPException(status_code=409, detail=_ambiguous_contact_detail(err)) from err
if not contact:
raise HTTPException(status_code=404, detail=not_found_detail)
return contact
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())
# Send login with password
logger.info("Sending login to repeater %s", contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
raise HTTPException(status_code=401, detail=f"Login failed: {login_result.payload}")
# Wait for key exchange to complete before sending requests
logger.debug("Waiting %.1fs for key exchange to complete", REPEATER_OP_DELAY_SECONDS)
await asyncio.sleep(REPEATER_OP_DELAY_SECONDS)
async def _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}"
)
@router.get("", response_model=list[Contact])
@@ -72,6 +68,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
@@ -88,7 +98,7 @@ async def create_contact(
raise HTTPException(status_code=400, detail="Invalid public key: must be valid hex") from e
# Check if contact already exists
existing = await ContactRepository.get_by_key_or_prefix(request.public_key)
existing = await ContactRepository.get_by_key(request.public_key)
if existing:
# Update name if provided
if request.name:
@@ -100,6 +110,7 @@ async def create_contact(
"flags": existing.flags,
"last_path": existing.last_path,
"last_path_len": existing.last_path_len,
"out_path_hash_mode": existing.out_path_hash_mode,
"last_advert": existing.last_advert,
"lat": existing.lat,
"lon": existing.lon,
@@ -108,7 +119,9 @@ async def create_contact(
"last_contacted": existing.last_contacted,
}
)
existing.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:
@@ -119,13 +132,15 @@ async def create_contact(
return existing
# Create new contact
lower_key = request.public_key.lower()
contact_data = {
"public_key": request.public_key,
"public_key": lower_key,
"name": request.name,
"type": 0, # Unknown
"flags": 0,
"last_path": None,
"last_path_len": -1,
"out_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
@@ -134,32 +149,131 @@ async def create_contact(
"last_contacted": None,
}
await ContactRepository.upsert(contact_data)
logger.info("Created contact %s", request.public_key[:12])
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])
# Backfill sender_key on channel messages that match this contact's name
if request.name:
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, request.name)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s", backfilled, request.name
)
# Trigger historical decryption if requested
if request.try_historical:
await start_historical_dm_decryption(background_tasks, request.public_key, request.name)
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
return Contact(**contact_data)
@router.get("/{public_key}/detail", response_model=ContactDetail)
async def get_contact_detail(public_key: str) -> ContactDetail:
"""Get comprehensive contact profile data.
Returns contact info, name history, message counts, most active rooms,
advertisement paths, advert frequency, and nearest repeaters.
"""
contact = await _resolve_contact_or_404(public_key)
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
# Compute advert observation rate (observations/hour) from path data.
# Note: a single advertisement can arrive via multiple paths, so this counts
# RF observations, not unique advertisement broadcasts.
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
for p in advert_paths:
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)
async def get_contact(public_key: str) -> Contact:
"""Get a specific contact by public key or prefix."""
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
return 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}")
@@ -167,12 +281,30 @@ 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)
Contact.from_radio_dict(lower_key, contact_data, on_radio=True)
)
synced_keys.append(lower_key)
claimed = await MessageRepository.claim_prefix_messages(lower_key)
if claimed > 0:
logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12])
adv_name = contact_data.get("adv_name")
if adv_name:
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, adv_name)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
adv_name,
)
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}
@@ -180,25 +312,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 ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
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"}
@@ -207,23 +340,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 ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found in database")
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"}
@@ -232,9 +364,7 @@ async def add_contact_to_radio(public_key: str) -> dict:
@router.post("/{public_key}/mark-read")
async def mark_contact_read(public_key: str) -> dict:
"""Mark a contact conversation as read (update last_read_at timestamp)."""
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
contact = await _resolve_contact_or_404(public_key)
updated = await ContactRepository.update_last_read_at(contact.public_key)
if not updated:
@@ -246,241 +376,112 @@ async def mark_contact_read(public_key: str) -> dict:
@router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present)."""
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
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.
@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.
The contact must be a repeater (type=2). If not on the radio, it will be added.
Uses login + status request with retry logic.
The trace path contains the contact's 1-byte pubkey hash as the sole hop
(no intermediate repeaters). The radio firmware requires at least one
node in the path.
"""
mc = require_connected()
require_connected()
# Get contact from database
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
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})",
tag = random.randint(1, 0xFFFFFFFF)
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
contact_hash = contact.public_key[:2]
# 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) as mc:
# Ensure contact is on radio so the trace can reach them
await _ensure_on_radio(mc, contact)
logger.info(
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
)
result = await mc.commands.send_trace(path=contact_hash, tag=tag)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
# Wait for the matching TRACE_DATA event
event = await mc.wait_for_event(
EventType.TRACE_DATA,
attribute_filters={"tag": tag},
timeout=15,
)
# Prepare connection (add/remove dance + login)
await prepare_repeater_connection(mc, contact, request.password)
if event is None:
raise HTTPException(status_code=504, detail="No trace response heard")
# 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...")
trace = event.payload
path = trace.get("path", [])
path_len = trace.get("path_len", 0)
if not status:
raise HTTPException(status_code=504, detail="No response from repeater after 3 attempts")
# remote_snr: first entry in path (what the target heard us at)
remote_snr = path[0]["snr"] if path else None
# local_snr: last entry in path (what we heard them at on the bounce-back)
local_snr = path[-1]["snr"] if path else None
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})"),
)
)
# 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,
logger.info(
"Trace result for %s: path_len=%d, remote_snr=%s, local_snr=%s",
contact.public_key[:12],
path_len,
remote_snr,
local_snr,
)
return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len)
@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).
@router.post("/{public_key}/reset-path")
async def reset_contact_path(public_key: str) -> dict:
"""Reset a contact's routing path to flood."""
contact = await _resolve_contact_or_404(public_key)
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 ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# 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})",
)
# Pause message polling to prevent it from stealing our response
async with pause_polling():
# Stop auto-fetch to prevent race condition where it consumes our CLI response
# before we can call get_msg(). This was causing every other command to fail
# with {'messages_available': False}.
await mc.stop_auto_message_fetching()
await ContactRepository.update_path(contact.public_key, "", -1, -1)
logger.info("Reset path to flood for %s", contact.public_key[:12])
# Push the updated path to radio if connected and contact is on radio
if radio_manager.is_connected and contact.on_radio:
try:
# 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())
updated = await ContactRepository.get_by_key(contact.public_key)
if updated:
async with radio_manager.radio_operation("reset_path_on_radio") as mc:
await mc.commands.add_contact(updated.to_radio_dict())
except Exception:
logger.warning("Failed to push flood path to radio for %s", contact.public_key[:12])
# Send the command
logger.info(
"Sending command to repeater %s: %s", contact.public_key[:12], request.command
)
# Broadcast updated contact so frontend refreshes
from app.websocket import broadcast_event
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
updated_contact = await ContactRepository.get_by_key(contact.public_key)
if updated_contact:
broadcast_event("contact", updated_contact.model_dump())
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})"
)
finally:
# Always restart auto-fetch, even if an error occurred
await mc.start_auto_message_fetching()
return {"status": "ok", "public_key": contact.public_key}

326
app/routers/fanout.py Normal file
View 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}

View File

@@ -1,4 +1,5 @@
import os
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
@@ -13,15 +14,16 @@ router = APIRouter(tags=["health"])
class HealthResponse(BaseModel):
status: str
radio_connected: bool
serial_port: str | None
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
@router.get("/health", response_model=HealthResponse)
async def healthcheck() -> HealthResponse:
"""Check if the API is running and if the radio is connected."""
# Get database file size in MB
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
@@ -29,17 +31,45 @@ async def healthcheck() -> HealthResponse:
except OSError:
pass
# Get oldest undecrypted packet info (gracefully handle if DB not connected)
oldest_ts = None
try:
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
except RuntimeError:
pass # Database not connected
return HealthResponse(
status="ok" if radio_manager.is_connected else "degraded",
radio_connected=radio_manager.is_connected,
serial_port=radio_manager.port,
database_size_mb=db_size_mb,
oldest_undecrypted_timestamp=oldest_ts,
)
# 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
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
return {
"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,
}
@router.get("/health", response_model=HealthResponse)
async def healthcheck() -> HealthResponse:
"""Check if the API is running and if the radio is connected."""
data = await build_health_data(radio_manager.is_connected, radio_manager.connection_info)
return HealthResponse(**data)

View File

@@ -6,13 +6,42 @@ 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.repository import MessageRepository
from app.models import (
Message,
MessagesAroundResponse,
SendChannelMessageRequest,
SendDirectMessageRequest,
)
from app.radio import radio_manager
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
from app.websocket import 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),
@@ -21,75 +50,98 @@ async def list_messages(
conversation_key: str | None = Query(
default=None, description="Filter by conversation key (channel key or contact pubkey)"
),
before: int | None = Query(
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,
msg_type=type,
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("/bulk", response_model=dict[str, list[Message]])
async def get_messages_bulk(
conversations: list[dict],
limit_per_conversation: int = Query(default=100, ge=1, le=1000),
) -> dict[str, list[Message]]:
"""Fetch messages for multiple conversations in one request.
Body should be a list of {type: 'PRIV'|'CHAN', conversation_key: string}.
Returns a dict mapping 'type:conversation_key' to list of messages.
"""
return await MessageRepository.get_bulk(conversations, limit_per_conversation)
@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
db_contact = await ContactRepository.get_by_key_or_prefix(request.destination)
try:
db_contact = await ContactRepository.get_by_key_or_prefix(request.destination)
except AmbiguousPublicKeyPrefixError as err:
sample = ", ".join(key[:12] for key in err.matches[:2])
raise HTTPException(
status_code=409,
detail=(
f"Ambiguous destination key prefix '{err.prefix}'. "
f"Use a full 64-character public key. Matching contacts: {sample}"
),
) from err
if not db_contact:
raise HTTPException(
status_code=404, detail=f"Contact not found in database: {request.destination}"
)
# Check if contact is on radio, if not add it
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
if not contact:
logger.info("Adding contact %s to radio before sending", db_contact.public_key[:12])
contact_data = db_contact.to_radio_dict()
# Always add/update the contact on radio before sending.
# The library cache (get_contact_by_key_prefix) can be stale after radio reboot,
# so we can't rely on it to know if the firmware has the contact.
# add_contact is idempotent - updates if exists, adds if not.
contact_data = db_contact.to_radio_dict()
async with radio_manager.radio_operation("send_direct_message") as mc:
logger.debug("Ensuring contact %s is on radio before sending", db_contact.public_key[:12])
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
# Continue anyway - might still work
# Continue anyway - might still work if contact exists
# Get the contact from radio again
# 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:
# Use the contact_data we built as fallback
contact = contact_data
logger.info("Sending direct message to %s", db_contact.public_key[:12])
logger.info("Sending direct message to %s", db_contact.public_key[:12])
result = await mc.commands.send_msg(
dst=contact,
msg=request.text,
)
# 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
now = int(time.time())
message_id = await MessageRepository.create(
msg_type="PRIV",
text=request.text,
conversation_key=db_contact.public_key,
conversation_key=db_contact.public_key.lower(),
sender_timestamp=now,
received_at=now,
outgoing=True,
@@ -101,7 +153,7 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
)
# Update last_contacted for the contact
await ContactRepository.update_last_contacted(db_contact.public_key, now)
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")
@@ -111,10 +163,10 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
track_pending_ack(ack_code, message_id, suggested_timeout)
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
return Message(
message = Message(
id=message_id,
type="PRIV",
conversation_key=db_contact.public_key,
conversation_key=db_contact.public_key.lower(),
text=request.text,
sender_timestamp=now,
received_at=now,
@@ -122,6 +174,12 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
acked=0,
)
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
# Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch.
broadcast_event("message", message.model_dump())
return message
# Temporary radio slot used for sending channel messages
TEMP_RADIO_SLOT = 0
@@ -130,7 +188,7 @@ TEMP_RADIO_SLOT = 0
@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
@@ -158,53 +216,96 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
TEMP_RADIO_SLOT,
expected_hash,
)
# 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,
)
# Continue anyway - the channel might already be correctly configured
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=request.text,
)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
# Store outgoing message with sender prefix (to match echo format)
# The radio includes "SenderName: " prefix when broadcasting, so we store it the same way
# to enable proper deduplication when the echo comes back
now = int(time.time())
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 = 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",
message_id: int | None = None
now: int | None = None
radio_name: str = ""
text_with_sender: str = request.text
our_public_key: str | None = None
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}: {request.text}" if radio_name else request.text
# Load the channel to a temporary radio slot before sending
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
logger.warning(
"Failed to set channel on radio slot %d before sending: %s",
TEMP_RADIO_SLOT,
set_result.payload,
)
raise HTTPException(
status_code=500,
detail="Failed to configure channel on radio before sending message",
)
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
# and the database. This ensures the echo's timestamp matches our stored message
# for proper deduplication.
now = int(time.time())
timestamp_bytes = now.to_bytes(4, "little")
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=request.text,
timestamp=timestamp_bytes,
)
return Message(
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
# Store outgoing immediately after send to avoid a race where
# our own echo lands before persistence.
message_id = await MessageRepository.create(
msg_type="CHAN",
text=text_with_sender,
conversation_key=channel_key_upper,
sender_timestamp=now,
received_at=now,
outgoing=True,
sender_name=radio_name or None,
sender_key=our_public_key,
)
if message_id is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
# Broadcast immediately so all connected clients see the message promptly.
# This ensures the message exists in frontend state when echo-driven
# `message_acked` events arrive.
broadcast_event(
"message",
Message(
id=message_id,
type="CHAN",
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=0,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=db_channel.name,
).model_dump(),
)
if message_id is None or now is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
acked_count, paths = await MessageRepository.get_ack_and_paths(message_id)
message = Message(
id=message_id,
type="CHAN",
conversation_key=channel_key_upper,
@@ -212,5 +313,150 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=0,
acked=acked_count,
paths=paths,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=db_channel.name,
)
return message
RESEND_WINDOW_SECONDS = 30
@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")
# Choose timestamp: original for byte-perfect, fresh for new-timestamp
if new_timestamp:
now = int(time.time())
timestamp_bytes = now.to_bytes(4, "little")
else:
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
try:
key_bytes = bytes.fromhex(msg.conversation_key)
except ValueError:
raise HTTPException(
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
) from None
resend_public_key: str | None = None
async with radio_manager.radio_operation("resend_channel_message") as mc:
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
text_to_send = msg.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail="Failed to configure channel on radio before resending",
)
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=text_to_send,
timestamp=timestamp_bytes,
)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to resend message: {result.payload}"
)
# For new-timestamp resend, create a new message row and broadcast it
if new_timestamp:
new_msg_id = await MessageRepository.create(
msg_type="CHAN",
text=msg.text,
conversation_key=msg.conversation_key,
sender_timestamp=now,
received_at=now,
outgoing=True,
sender_name=radio_name or None,
sender_key=resend_public_key,
)
if new_msg_id is None:
# Timestamp-second collision (same text+channel within the same second).
# The radio already transmitted, so log and return the original ID rather
# than surfacing a 500 for a message that was successfully sent over the air.
logger.warning(
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
message_id,
)
return {"status": "ok", "message_id": message_id}
broadcast_event(
"message",
Message(
id=new_msg_id,
type="CHAN",
conversation_key=msg.conversation_key,
text=msg.text,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=0,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=db_channel.name,
).model_dump(),
)
logger.info(
"Resent channel message %d as new message %d to %s",
message_id,
new_msg_id,
db_channel.name,
)
return {"status": "ok", "message_id": new_msg_id}
logger.info("Resent channel message %d to %s", message_id, db_channel.name)
return {"status": "ok", "message_id": message_id}

View File

@@ -1,6 +1,8 @@
import logging
from hashlib import sha256
from sqlite3 import OperationalError
import aiosqlite
from fastapi import APIRouter, BackgroundTasks
from pydantic import BaseModel, Field
@@ -63,11 +65,14 @@ async def _run_historical_channel_decryption(
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
channel_name=display_name,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
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:
@@ -208,7 +213,7 @@ async def decrypt_historical_packets(
# Try to find contact name for display
from app.repository import ContactRepository
contact = await ContactRepository.get_by_key_or_prefix(contact_public_key_hex)
contact = await ContactRepository.get_by_key(contact_public_key_hex)
display_name = contact.name if contact else None
background_tasks.add_task(
@@ -233,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",
)
@@ -246,21 +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 (must be outside transaction, use executescript)
await db.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)

View File

@@ -5,6 +5,8 @@ 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
logger = logging.getLogger(__name__)
@@ -26,6 +28,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):
@@ -34,6 +42,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):
@@ -62,46 +76,68 @@ 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()
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
async with radio_manager.radio_operation("update_radio_config") as mc:
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
if update.lat is not None or update.lon is not None:
current_info = mc.self_info
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
logger.info("Setting radio coordinates to %f, %f", lat, lon)
await mc.commands.set_coords(lat=lat, lon=lon)
if update.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.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.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,
)
# Sync time with system clock
await sync_radio_time()
if update.path_hash_mode is not None:
if not radio_manager.path_hash_mode_supported:
raise HTTPException(
status_code=400, detail="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 HTTPException(
status_code=500,
detail=f"Failed to set path hash mode: {result.payload}",
)
radio_manager.path_hash_mode = update.path_hash_mode
# Sync time with system clock
await sync_radio_time(mc)
# Re-fetch self_info so the response reflects the changes we just made.
# Commands like set_name() write to flash but don't update the cached
# self_info — send_appstart() triggers a fresh SELF_INFO from the radio.
await mc.commands.send_appstart()
return await get_radio_config()
@@ -109,7 +145,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)
@@ -117,30 +153,83 @@ 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:
result = await mc.commands.import_private_key(key_bytes)
if result.type == EventType.ERROR:
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to import private key: {result.payload}"
)
# Re-export from radio so the server-side keystore uses the new key
# for DM decryption immediately, rather than waiting for reconnect.
from app.keystore import export_and_store_private_key
keystore_refreshed = await export_and_store_private_key(mc)
if not keystore_refreshed:
logger.warning("Keystore refresh failed after import, retrying once")
keystore_refreshed = await export_and_store_private_key(mc)
if not keystore_refreshed:
raise HTTPException(
status_code=500, detail=f"Failed to import private key: {result.payload}"
status_code=500,
detail=(
"Private key imported on radio, but server-side keystore "
"refresh failed. Reconnect to apply the new key for DM decryption."
),
)
return {"status": "ok"}
@router.post("/advertise")
async def send_advertisement(flood: bool = True) -> dict:
"""Send a radio advertisement to announce presence on the mesh."""
mc = require_connected()
async def send_advertisement() -> dict:
"""Send a flood advertisement to announce presence on the mesh.
logger.info("Sending advertisement (flood=%s)", flood)
result = await mc.commands.send_advert(flood=flood)
Manual advertisement requests always send immediately, updating the
last_advert_time which affects when the next periodic/startup advert
can occur.
if result.type == EventType.ERROR:
Returns:
status: "ok" if sent successfully
"""
require_connected()
logger.info("Sending flood advertisement")
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")
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,
}
success = await radio_manager.reconnect()
if not success:
raise HTTPException(
status_code=500, detail=f"Failed to send advertisement: {result.payload}"
status_code=503, detail="Failed to reconnect. Check radio connection and power."
)
return {"status": "ok", "flood": flood}
try:
await radio_manager.post_connect_setup()
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
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
@router.post("/reboot")
@@ -150,42 +239,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).
"""
from app.radio import radio_manager
# 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:
# Re-register event handlers after successful reconnect
from app.event_handlers import register_event_handlers
if radio_manager.meshcore:
register_event_handlers(radio_manager.meshcore)
await radio_manager.meshcore.start_auto_message_fetching()
logger.info("Event handlers re-registered and auto message fetching started")
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")
@@ -196,33 +260,20 @@ async def reconnect_radio() -> dict:
if no specific port is configured. Useful when the radio has been disconnected
or power-cycled.
"""
from app.radio import radio_manager
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 radio_manager.post_connect_setup()
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:
# Re-register event handlers after successful reconnect
from app.event_handlers import register_event_handlers
if radio_manager.meshcore:
register_event_handlers(radio_manager.meshcore)
# Restart auto message fetching
await radio_manager.meshcore.start_auto_message_fetching()
logger.info("Event handlers re-registered and auto message fetching started")
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()

View File

@@ -5,25 +5,52 @@ import time
from fastapi import APIRouter
from app.database import db
from app.models import UnreadCounts
from app.radio import radio_manager
from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/read-state", tags=["read-state"])
@router.get("/unreads", response_model=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.
"""
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)
@router.post("/mark-all-read")
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())
# Update all contacts and channels in one transaction
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (now,))
await db.conn.execute("UPDATE channels SET last_read_at = ?", (now,))
await db.conn.commit()
await ContactRepository.mark_all_read(now)
await ChannelRepository.mark_all_read(now)
logger.info("Marked all contacts and channels as read at %d", now)
return {"status": "ok", "timestamp": now}

510
app/routers/repeaters.py Normal file
View 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.radio import radio_manager
from app.repository import ContactRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
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
- 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,
)

View File

@@ -1,3 +1,4 @@
import asyncio
import logging
from typing import Literal
@@ -16,7 +17,9 @@ class AppSettingsUpdate(BaseModel):
default=None,
ge=1,
le=1000,
description="Maximum non-repeater contacts to keep on radio (1-1000)",
description=(
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
),
)
auto_decrypt_dm_on_advert: bool | None = Field(
default=None,
@@ -26,6 +29,31 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Sidebar sort order: 'recent' or 'alpha'",
)
advert_interval: int | None = Field(
default=None,
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
)
flood_scope: str | None = Field(
default=None,
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):
@@ -33,13 +61,6 @@ class FavoriteRequest(BaseModel):
id: str = Field(description="Channel key or contact public key")
class LastMessageTimeUpdate(BaseModel):
state_key: str = Field(
description="Conversation state key (e.g., 'channel-KEY' or 'contact-PREFIX')"
)
timestamp: int = Field(description="Unix timestamp of the last message")
class MigratePreferencesRequest(BaseModel):
favorites: list[FavoriteRequest] = Field(
default_factory=list,
@@ -85,26 +106,48 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
if update.advert_interval is not None:
# Enforce minimum 1-hour interval; 0 means disabled
interval = update.advert_interval
if 0 < interval < 3600:
interval = 3600
logger.info("Updating advert_interval to %d", interval)
kwargs["advert_interval"] = interval
# 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:
stripped = update.flood_scope.strip()
kwargs["flood_scope"] = stripped
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.radio import 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()
@router.post("/favorites", response_model=AppSettings)
async def add_favorite(request: FavoriteRequest) -> AppSettings:
"""Add a conversation to favorites."""
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
return await AppSettingsRepository.add_favorite(request.type, request.id)
@router.delete("/favorites", response_model=AppSettings)
async def remove_favorite(request: FavoriteRequest) -> AppSettings:
"""Remove a conversation from favorites."""
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
return await AppSettingsRepository.remove_favorite(request.type, request.id)
@router.post("/favorites/toggle", response_model=AppSettings)
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
"""Toggle a conversation's favorite status."""
@@ -113,21 +156,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 favorite changes, sync the radio so the contact is
# loaded/unloaded immediately rather than waiting for the next advert.
if request.type == "contact":
from app.radio_sync import sync_recent_contacts_to_radio
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
return result
@router.post("/last-message-time")
async def update_last_message_time(request: LastMessageTimeUpdate) -> dict:
"""Update the last message time for a conversation.
@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)
Used to track when conversations last received messages for sidebar sorting.
Only updates if the new timestamp is greater than the existing one.
"""
await AppSettingsRepository.update_last_message_time(request.state_key, request.timestamp)
return {"status": "ok"}
@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
View 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)

View File

@@ -1,13 +1,11 @@
"""WebSocket router for real-time updates."""
import logging
import os
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.config import settings
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository, RawPacketRepository
from app.routers.health import build_health_data
from app.websocket import ws_manager
logger = logging.getLogger(__name__)
@@ -16,60 +14,20 @@ router = APIRouter()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
"""WebSocket endpoint for real-time updates."""
"""WebSocket endpoint for real-time updates.
Only sends health status on initial connect. Contacts and channels
are fetched via REST endpoints for faster parallel loading.
"""
await ws_manager.connect(websocket)
# Send initial state
# Send initial health status
try:
# Health status
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
db_size_mb = round(db_size_bytes / (1024 * 1024), 2)
except OSError:
pass
# Get oldest undecrypted packet info
oldest_ts = None
try:
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
except RuntimeError:
pass # Database not connected
health_data = {
"status": "ok" if radio_manager.is_connected else "degraded",
"radio_connected": radio_manager.is_connected,
"serial_port": radio_manager.port,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
}
health_data = await build_health_data(
radio_manager.is_connected, radio_manager.connection_info
)
await ws_manager.send_personal(websocket, "health", health_data)
# Contacts - fetch all by paginating until exhausted
all_contacts = []
chunk_size = 500
offset = 0
while True:
chunk = await ContactRepository.get_all(limit=chunk_size, offset=offset)
all_contacts.extend(chunk)
if len(chunk) < chunk_size:
break
offset += chunk_size
await ws_manager.send_personal(
websocket,
"contacts",
[c.model_dump() for c in all_contacts],
)
# Channels
channels = await ChannelRepository.get_all()
await ws_manager.send_personal(
websocket,
"channels",
[c.model_dump() for c in channels],
)
except Exception as e:
logger.error("Error sending initial state: %s", e)

View File

@@ -3,13 +3,10 @@
import asyncio
import json
import logging
import os
from typing import Any
from fastapi import WebSocket
from app.config import settings
logger = logging.getLogger(__name__)
# Timeout for individual WebSocket send operations (seconds)
@@ -95,14 +92,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.
@@ -126,35 +136,13 @@ def broadcast_success(message: str, details: str | None = None) -> None:
asyncio.create_task(ws_manager.broadcast("success", data))
def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None:
def broadcast_health(radio_connected: bool, connection_info: str | None = None) -> None:
"""Broadcast health status change to all connected clients."""
from app.repository import RawPacketRepository
async def _broadcast():
# Get database file size in MB
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
db_size_mb = round(db_size_bytes / (1024 * 1024), 2)
except OSError:
pass
from app.routers.health import build_health_data
# Get oldest undecrypted packet info
oldest_ts = None
try:
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
except RuntimeError:
pass # Database not connected
await ws_manager.broadcast(
"health",
{
"status": "ok" if radio_connected else "degraded",
"radio_connected": radio_connected,
"serial_port": serial_port,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
},
)
data = await build_health_data(radio_connected, connection_info)
await ws_manager.broadcast("health", data)
asyncio.create_task(_broadcast())

BIN
app_screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

32
docker-compose.yaml Normal file
View 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
View File

@@ -0,0 +1 @@
install-links=true

361
frontend/AGENTS.md Normal file
View File

@@ -0,0 +1,361 @@
# Frontend AGENTS.md
This document is the frontend working guide for agents and developers.
Keep it aligned with `frontend/src` source code.
## Stack
- React 18 + TypeScript
- Vite
- Vitest + Testing Library
- shadcn/ui primitives
- 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`
## Frontend Map
```text
frontend/src/
├── main.tsx # React entry point (StrictMode, root render)
├── App.tsx # App shell and orchestration
├── api.ts # Typed REST client
├── types.ts # Shared TS contracts
├── useWebSocket.ts # WS lifecycle + event dispatch
├── 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/
│ ├── index.ts # Central re-export of all hooks
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
│ ├── 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
├── utils/
│ ├── 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
│ ├── SearchView.tsx # Full-text message search pane
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── RawPacketList.tsx
│ ├── MapView.tsx
│ ├── VisualizerView.tsx
│ ├── PacketVisualizer3D.tsx
│ ├── PathModal.tsx
│ ├── PathRouteMap.tsx
│ ├── CrackerPanel.tsx
│ ├── BotCodeEditor.tsx
│ ├── ContactAvatar.tsx
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
│ ├── ContactStatusInfo.tsx # Contact status info component
│ ├── 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
├── contactAvatar.test.ts
├── integration.test.ts
├── messageCache.test.ts
├── messageParser.test.ts
├── pathUtils.test.ts
├── prefetch.test.ts
├── radioPresets.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
├── useConversationMessages.race.test.ts
├── useRepeaterDashboard.test.ts
├── useContactsAndChannels.test.ts
├── useWebSocket.dispatch.test.ts
└── useWebSocket.lifecycle.test.ts
```
## Architecture Notes
### State ownership
`App.tsx` orchestrates high-level state and delegates to hooks:
- `useRadioControl`: radio health/config state, reconnect/reboot polling
- `useAppSettings`: settings CRUD, favorites, preferences migration
- `useContactsAndChannels`: contact/channel lists, creation, deletion
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationMessages`: fetch, pagination, dedup/update helpers
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
### Initial load + realtime
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
- WebSocket: realtime deltas/events.
- 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 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` 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`, `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`)
Supported routes:
- `#raw`
- `#map`
- `#map/focus/{pubkey_or_prefix}`
- `#visualizer`
- `#search`
- `#channel/{channelKey}`
- `#channel/{channelKey}/{label}`
- `#contact/{publicKey}`
- `#contact/{publicKey}/{label}`
Legacy name-based hashes are still accepted for compatibility.
## Conversation State Keys (`utils/conversationState.ts`)
`getStateKey(type, id)` produces:
- channels: `channel-{channelKey}`
- contacts: `contact-{publicKey}`
Use full contact public key here (not 12-char prefix).
`conversationState.ts` keeps an in-memory cache and localStorage helpers used for migration/compatibility.
Canonical persistence for unread and sort metadata is server-side (`app_settings` + read-state endpoints).
## Utilities
### `utils/pubkey.ts`
Current public export:
- `getContactDisplayName(name, pubkey)`
It falls back to a 12-char prefix when `name` is missing.
### `utils/pathUtils.ts`
Distance/validation helpers used by path + map UI.
### `utils/favorites.ts`
LocalStorage migration helpers for favorites; canonical favorites are server-side.
## Types and Contracts (`types.ts`)
`AppSettings` currently includes:
- `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`
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
`HealthStatus` includes `fanout_statuses: Record<string, FanoutStatusEntry>` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`.
`FanoutConfig` represents a single fanout integration: `{id, type, name, enabled, config, scope, sort_order, created_at}`.
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
## 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: `infoPaneContactKey` in App.tsx controls open/close. 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: `infoPaneChannelKey` in App.tsx controls open/close. Live channel data from the `channels` array is preferred over the initial detail snapshot.
## Repeater Dashboard
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
**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` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation.
- **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` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
## Styling
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
Do not rely on old class-only layout assumptions.
## Security Posture (intentional)
- No authentication UI.
- Frontend assumes trusted network usage.
- Bot editor intentionally allows arbitrary backend bot code configuration.
## 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
npm run build
```
When touching cross-layer contracts, also run backend tests from repo root:
```bash
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.
2. If URL/hash behavior changes, update `utils/urlHash.ts` tests.
3. If read/unread semantics change, update `useUnreadCounts` tests.
4. Keep this file concise; prefer source links over speculative detail.

View File

@@ -1,718 +0,0 @@
# Frontend CLAUDE.md
This document provides context for AI assistants and developers working on the React frontend.
## Technology Stack
- **React 18** - UI framework with hooks
- **TypeScript** - Type safety
- **Vite** - Build tool with HMR
- **Vitest** - Testing framework
- **Sonner** - Toast notifications
- **shadcn/ui components** - Sheet, Tabs, Button (in `components/ui/`)
- **meshcore-hashtag-cracker** - WebGPU-accelerated channel key bruteforcing
- **nosleep.js** - Prevents device sleep during cracking
- **leaflet / react-leaflet** - Interactive map for node locations
## Directory Structure
```
frontend/
├── src/
│ ├── main.tsx # Entry point, renders App
│ ├── App.tsx # Main component, all state management
│ ├── api.ts # REST API client
│ ├── types.ts # TypeScript interfaces
│ ├── useWebSocket.ts # WebSocket hook with auto-reconnect
│ ├── styles.css # Dark theme CSS
│ ├── utils/
│ │ ├── messageParser.ts # Text parsing utilities
│ │ ├── conversationState.ts # localStorage for message times (sidebar sorting)
│ │ ├── pubkey.ts # Public key utilities (prefix matching, display names)
│ │ └── contactAvatar.ts # Avatar generation (colors, initials/emoji)
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ │ ├── sonner.tsx # Toast notifications (Sonner wrapper)
│ │ │ ├── sheet.tsx # Slide-out panel
│ │ │ ├── tabs.tsx # Tab navigation
│ │ │ └── button.tsx # Button component
│ │ ├── StatusBar.tsx # Radio status, reconnect button, config button
│ │ ├── Sidebar.tsx # Contacts/channels list, search, unread badges
│ │ ├── MessageList.tsx # Message display, avatars, clickable senders
│ │ ├── MessageInput.tsx # Text input with imperative handle
│ │ ├── ContactAvatar.tsx # Contact profile image component
│ │ ├── RawPacketList.tsx # Raw packet feed display
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker (lazy-loads wordlist)
│ │ ├── NewMessageModal.tsx
│ │ └── SettingsModal.tsx # Unified settings: radio config, identity, serial, database, advertise
│ └── test/
│ ├── setup.ts # Test setup (jsdom, matchers)
│ ├── messageParser.test.ts
│ ├── unreadCounts.test.ts
│ ├── contactAvatar.test.ts
│ ├── messageDeduplication.test.ts
│ └── websocket.test.ts
├── index.html
├── vite.config.ts # API proxy config
├── tsconfig.json
└── package.json
```
## State Management
All application state lives in `App.tsx` using React hooks. No external state library.
### Core State
```typescript
const [health, setHealth] = useState<HealthStatus | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [contacts, setContacts] = useState<Contact[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
```
### App Settings
App settings are stored server-side and include:
- `favorites` - List of favorited conversations (channels/contacts)
- `sidebar_sort_order` - 'recent' or 'alpha'
- `auto_decrypt_dm_on_advert` - Auto-decrypt historical DMs on new contact
- `last_message_times` - Map of conversation keys to last message timestamps
**Migration**: On first load, localStorage preferences are migrated to the server.
The `preferences_migrated` flag prevents duplicate migrations.
### State Flow
1. **WebSocket** pushes real-time updates (health, contacts, channels, messages)
2. **REST API** fetches initial data and handles user actions
3. **Components** receive state as props, call handlers to trigger changes
### Conversation Header
For contacts, the header shows path information alongside "Last heard":
- `(Last heard: 10:30 AM, direct)` - Direct neighbor (path_len=0)
- `(Last heard: 10:30 AM, 2 hops)` - Routed through repeaters (path_len>0)
- `(Last heard: 10:30 AM, flood)` - No known path (path_len=-1)
## WebSocket (`useWebSocket.ts`)
The `useWebSocket` hook manages real-time connection:
```typescript
const wsHandlers = useMemo(() => ({
onHealth: (data: HealthStatus) => setHealth(data),
onMessage: (msg: Message) => { /* add to list, track unread */ },
onMessageAcked: (messageId: number, ackCount: number) => { /* update ack count */ },
// ...
}), []);
useWebSocket(wsHandlers);
```
### Features
- **Auto-reconnect**: Reconnects after 3 seconds on disconnect
- **Heartbeat**: Sends ping every 30 seconds
- **Event types**: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
- **Error handling**: `onError` handler displays toast notifications for backend errors
### URL Detection
```typescript
const isDev = window.location.port === '5173';
const wsUrl = isDev
? 'ws://localhost:8000/api/ws'
: `${protocol}//${window.location.host}/api/ws`;
```
## API Client (`api.ts`)
Typed REST client with consistent error handling:
```typescript
import { api } from './api';
// Health
await api.getHealth();
// Radio
await api.getRadioConfig();
await api.updateRadioConfig({ name: 'MyRadio' });
await api.sendAdvertisement(true);
// Contacts/Channels
await api.getContacts();
await api.createContact(publicKey, name, tryHistorical); // Create contact, optionally decrypt historical DMs
await api.getChannels();
await api.createChannel('#test');
// Messages
await api.getMessages({ type: 'CHAN', conversation_key: channelKey, limit: 200 });
await api.sendChannelMessage(channelKey, 'Hello');
await api.sendDirectMessage(publicKey, 'Hello');
// Historical decryption
await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: '#test' });
// Radio reconnection
await api.reconnectRadio(); // Returns { status, message, connected }
// Repeater telemetry
await api.requestTelemetry(publicKey, password); // Returns TelemetryResponse
// Repeater CLI commands (after login)
await api.sendRepeaterCommand(publicKey, 'ver'); // Returns CommandResponse
```
### API Proxy (Development)
Vite proxies `/api/*` to backend (backend routes are already prefixed with `/api`):
```typescript
// vite.config.ts
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
}
```
## Type Definitions (`types.ts`)
### Key Type Aliases
```typescript
type PublicKey = string; // 64-char hex identifying a contact/node
type PubkeyPrefix = string; // 12-char hex prefix (used in message routing)
type ChannelKey = string; // 32-char hex identifying a channel
```
### Key Interfaces
```typescript
interface Contact {
public_key: PublicKey;
name: string | null;
type: number; // 0=unknown, 1=client, 2=repeater, 3=room
on_radio: boolean;
last_path_len: number; // -1=flood, 0=direct, >0=hops through repeaters
last_path: string | null; // Hex routing path
last_seen: number | null; // Unix timestamp
// ...
}
interface Channel {
key: ChannelKey;
name: string;
is_hashtag: boolean;
on_radio: boolean;
}
interface Message {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string; // PublicKey for PRIV, ChannelKey for CHAN
text: string;
outgoing: boolean;
acked: number; // 0=not acked, 1+=ack count (flood echoes)
// ...
}
interface Conversation {
type: 'contact' | 'channel' | 'raw' | 'map';
id: string; // PublicKey for contacts, ChannelKey for channels, 'raw'/'map' for special views
name: string;
}
interface Favorite {
type: 'channel' | 'contact';
id: string; // Channel key or contact public key
}
interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: 'recent' | 'alpha';
last_message_times: Record<string, number>;
preferences_migrated: boolean;
}
// Repeater telemetry types
interface NeighborInfo {
pubkey_prefix: string;
name: string | null;
snr: number;
last_heard_seconds: number;
}
interface AclEntry {
pubkey_prefix: string;
name: string | null;
permission: number;
permission_name: string;
}
interface TelemetryResponse {
battery_volts: number;
uptime_seconds: number;
// ... status fields
neighbors: NeighborInfo[];
acl: AclEntry[];
}
interface CommandResponse {
command: string;
response: string;
sender_timestamp: number | null;
}
```
## Component Patterns
### MessageInput with Imperative Handle
Exposes `appendText` method for click-to-mention:
```typescript
export interface MessageInputHandle {
appendText: (text: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, isRepeaterMode }, ref) {
useImperativeHandle(ref, () => ({
appendText: (text: string) => {
setText((prev) => prev + text);
inputRef.current?.focus();
},
}));
// ...
}
);
// Usage in App.tsx
const messageInputRef = useRef<MessageInputHandle>(null);
messageInputRef.current?.appendText(`@[${sender}] `);
```
### Repeater Mode
Repeater contacts (type=2) have a two-phase interaction:
**Phase 1: Login (password mode)**
- Input type changes to `password`
- Button shows "Fetch" instead of "Send"
- Enter "." for empty password (converted to empty string)
- Submitting requests telemetry + logs in
**Phase 2: CLI commands (after login)**
- Input switches back to normal text
- Placeholder shows "Enter CLI command..."
- Commands sent via `/contacts/{key}/command` endpoint
- Responses displayed as local messages (not persisted to database)
```typescript
// State tracking
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
// Reset on conversation change
useEffect(() => {
setRepeaterLoggedIn(false);
}, [activeConversation?.id]);
// Mode switches after successful telemetry
const isRepeaterMode = activeContactIsRepeater && !repeaterLoggedIn;
<MessageInput
onSend={isRepeaterMode ? handleTelemetryRequest :
(repeaterLoggedIn ? handleRepeaterCommand : handleSendMessage)}
isRepeaterMode={isRepeaterMode}
placeholder={repeaterLoggedIn ? 'Enter CLI command...' : undefined}
/>
```
Telemetry response is displayed as three local messages (not persisted):
1. **Telemetry** - Battery voltage, uptime, signal quality, packet stats
2. **Neighbors** - Sorted by SNR (highest first), with resolved names
3. **ACL** - Access control list with permission levels
### Repeater Message Rendering
Repeater CLI responses often contain colons (e.g., `clock: 12:30:00`). To prevent
incorrect sender parsing, MessageList skips `parseSenderFromText()` for repeater contacts:
```typescript
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
const { sender, content } = isRepeater
? { sender: null, content: msg.text } // Preserve full text
: parseSenderFromText(msg.text);
```
### Unread Count Tracking
Uses refs to avoid stale closures in memoized handlers:
```typescript
const activeConversationRef = useRef<Conversation | null>(null);
// Keep ref in sync
useEffect(() => {
activeConversationRef.current = activeConversation;
}, [activeConversation]);
// In WebSocket handler (can safely access current value)
const activeConv = activeConversationRef.current;
```
### State Tracking Keys
State tracking keys (for message times used in sidebar sorting) are generated by `getStateKey()`:
```typescript
import { getStateKey } from './utils/conversationState';
// Channels: "channel-{channelKey}"
getStateKey('channel', channelKey) // e.g., "channel-8B3387E9C5CDEA6AC9E5EDBAA115CD72"
// Contacts: "contact-{12-char-prefix}"
getStateKey('contact', publicKey) // e.g., "contact-abc123def456"
```
**Note:** `getStateKey()` is NOT the same as `Message.conversation_key`. The state key is prefixed
for local state tracking, while `conversation_key` is the raw database field.
### Read State (Server-Side)
Unread tracking uses server-side `last_read_at` timestamps for cross-device consistency:
```typescript
// Contacts and channels include last_read_at from server
interface Contact {
// ...
last_read_at: number | null; // Unix timestamp when conversation was last read
}
// Mark as read via API (called automatically when viewing conversation)
await api.markContactRead(publicKey);
await api.markChannelRead(channelKey);
await api.markAllRead(); // Bulk mark all as read
```
Unread count = messages where `received_at > last_read_at`.
## Utility Functions
### Message Parser (`utils/messageParser.ts`)
```typescript
// Parse "sender: message" format from channel messages
parseSenderFromText(text: string): { sender: string | null; content: string }
// Format Unix timestamp to time string
formatTime(timestamp: number): string
```
### Public Key Utilities (`utils/pubkey.ts`)
Consistent handling of 64-char full keys and 12-char prefixes:
```typescript
import { getPubkeyPrefix, pubkeysMatch, getContactDisplayName } from './utils/pubkey';
// Extract 12-char prefix (works with full keys or existing prefixes)
getPubkeyPrefix(key) // "abc123def456..."
// Compare keys by prefix (handles mixed full/prefix comparisons)
pubkeysMatch(key1, key2) // true if prefixes match
// Get display name with fallback to prefix
getContactDisplayName(name, publicKey) // name or first 12 chars of key
```
### Conversation State (`utils/conversationState.ts`)
```typescript
import { getStateKey, setLastMessageTime, getLastMessageTimes } from './utils/conversationState';
// Generate state tracking key (NOT the same as Message.conversation_key)
getStateKey('channel', channelKey)
getStateKey('contact', publicKey)
// Track message times for sidebar sorting (stored in localStorage)
setLastMessageTime(stateKey, timestamp)
getLastMessageTimes() // Returns all tracked message times
```
**Note:** Read state (`last_read_at`) is tracked server-side, not in localStorage.
### Contact Avatar (`utils/contactAvatar.ts`)
Generates consistent profile "images" for contacts using hash-based colors:
```typescript
import { getContactAvatar, CONTACT_TYPE_REPEATER } from './utils/contactAvatar';
// Get avatar info for a contact
const avatar = getContactAvatar(name, publicKey, contactType);
// Returns: { text: 'JD', background: 'hsl(180, 60%, 40%)', textColor: '#ffffff' }
// Repeaters (type=2) always show 🛜 with gray background
const repeaterAvatar = getContactAvatar('Some Repeater', key, CONTACT_TYPE_REPEATER);
// Returns: { text: '🛜', background: '#444444', textColor: '#ffffff' }
```
Avatar text priority:
1. First emoji in name
2. Initials (first letter + first letter after space)
3. Single first letter
4. First 2 chars of public key (fallback)
## CSS Patterns
The app uses a minimal dark theme in `styles.css`.
### Key Classes
```css
.app /* Root container */
.status-bar /* Top bar with radio info */
.sidebar /* Left panel with contacts/channels */
.sidebar-item /* Individual contact/channel row */
.sidebar-item.unread /* Bold with badge */
.message-area /* Main content area */
.message-list /* Scrollable message container */
.message /* Individual message */
.message.outgoing /* Right-aligned, different color */
.message .sender /* Clickable sender name */
```
### Unread Badge
```css
.sidebar-item.unread .name {
font-weight: 700;
color: #fff;
}
.sidebar-item .unread-badge {
background: #4caf50;
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
}
```
## Testing
Run tests with:
```bash
cd frontend
npm run test:run # Single run
npm run test # Watch mode
```
### Test Files
- `messageParser.test.ts` - Sender extraction, time formatting, conversation keys
- `unreadCounts.test.ts` - Unread tracking logic
- `contactAvatar.test.ts` - Avatar text extraction, color generation, repeater handling
- `messageDeduplication.test.ts` - Message deduplication logic
- `websocket.test.ts` - WebSocket message routing
- `repeaterMode.test.ts` - Repeater CLI parsing, password "." conversion
### Test Setup
Tests use jsdom environment with `@testing-library/react`:
```typescript
// src/test/setup.ts
import '@testing-library/jest-dom';
```
## Common Tasks
### Adding a New Component
1. Create component in `src/components/`
2. Add TypeScript props interface
3. Import and use in `App.tsx` or parent component
4. Add styles to `styles.css`
### Adding a New API Endpoint
1. Add method to `api.ts`
2. Add/update types in `types.ts`
3. Call from `App.tsx` or component
### Adding New WebSocket Event
1. Add handler option to `UseWebSocketOptions` interface in `useWebSocket.ts`
2. Add case to `onmessage` switch
3. Provide handler in `wsHandlers` object in `App.tsx`
### Adding State
1. Add `useState` in `App.tsx`
2. Pass down as props to components
3. If needed in WebSocket handler, also use a ref to avoid stale closures
## Development Workflow
```bash
# Start dev server (hot reload)
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Run tests
npm run test:run
```
The dev server runs on port 5173 and proxies API requests to `localhost:8000`.
### Production Build
In production, the FastAPI backend serves the compiled frontend from `frontend/dist`:
```bash
npm run build
# Then run backend: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
## URL Hash Navigation
Deep linking to conversations via URL hash:
- `#channel/RoomName` - Opens a channel (leading `#` stripped from name for cleaner URLs)
- `#contact/ContactName` - Opens a DM
- `#raw` - Opens the raw packet feed
- `#map` - Opens the node map
```typescript
// Parse hash on initial load
const hashConv = parseHashConversation();
// Update hash when conversation changes (uses replaceState to avoid history pollution)
window.history.replaceState(null, '', newHash);
```
## CrackerPanel
The `CrackerPanel` component provides WebGPU-accelerated brute-forcing of channel keys for undecrypted GROUP_TEXT packets.
### Features
- **Dictionary attack first**: Uses `words.txt` wordlist
- **GPU bruteforce**: Falls back to character-by-character search
- **Queue management**: Automatically processes new packets as they arrive
- **Auto-channel creation**: Cracked channels are automatically added to the channel list
- **Configurable max length**: Adjustable while running (default: 6)
- **Retry failed**: Option to retry failed packets at increasing lengths
- **NoSleep integration**: Prevents device sleep during cracking via `nosleep.js`
- **Global collapsible panel**: Toggle from sidebar, runs in background when hidden
### Key Implementation Patterns
Uses refs to avoid stale closures in async callbacks:
```typescript
const isRunningRef = useRef(false);
const isProcessingRef = useRef(false); // Prevents concurrent GPU operations
const queueRef = useRef<Map<number, QueueItem>>(new Map());
const retryFailedRef = useRef(false);
const maxLengthRef = useRef(6);
```
Progress reporting shows rate in Mkeys/s or Gkeys/s depending on speed.
## MapView
The `MapView` component displays contacts with GPS coordinates on an interactive Leaflet map.
### Features
- **Location filtering**: Only shows contacts with lat/lon that were heard within the last 7 days
- **Freshness coloring**: Markers colored by how recently the contact was heard:
- Bright green (`#22c55e`) - less than 1 hour ago
- Light green (`#4ade80`) - less than 1 day ago
- Yellow-green (`#a3e635`) - less than 3 days ago
- Gray (`#9ca3af`) - older (up to 7 days)
- **Node/repeater distinction**: Regular nodes have black outlines, repeaters are larger with no outline
- **Geolocation**: Tries browser geolocation first, falls back to fitting all markers in view
- **Popups**: Click a marker to see contact name, last heard time, and coordinates
### Data Source
Contact location data (`lat`, `lon`) is extracted from advertisement packets in the backend (`decoder.py`).
The `last_seen` timestamp determines marker freshness.
## Sidebar Features
- **Sort toggle**: Default is 'recent' (most recent message first), can toggle to alphabetical
- **Mark all as read**: Button appears when there are unread messages, clears all unread counts
- **Cracker toggle**: Shows/hides the global cracker panel with running status indicator
## Toast Notifications
The app uses Sonner for toast notifications via a custom wrapper at `components/ui/sonner.tsx`:
```typescript
import { toast } from './components/ui/sonner';
// Success toast (use sparingly - only for significant/destructive actions)
toast.success('Channel deleted');
// Error toast with details
toast.error('Failed to send message', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
```
### Error Handling Pattern
All async operations that can fail should show error toasts. Keep console.error for debugging:
```typescript
try {
await api.someOperation();
} catch (err) {
console.error('Failed to do X:', err);
toast.error('Failed to do X', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
}
```
### Where Toasts Are Used
**Error toasts** (shown when operations fail):
- `App.tsx`: Advertisement, channel delete, contact delete
- `useConversationMessages.ts`: Message loading (initial and pagination)
- `MessageInput.tsx`: Message send, telemetry request
- `CrackerPanel.tsx`: Channel save after cracking, WebGPU unavailable
- `StatusBar.tsx`: Manual reconnection failure
- `useWebSocket.ts`: Backend errors via WebSocket `error` events
**Success toasts** (used sparingly for significant actions):
- Radio connection/disconnection status changes
- Manual reconnection success
- Advertisement sent, channel/contact deleted (confirmation of intentional actions)
**Avoid success toasts** for routine operations like sending messages - only show errors.
The `<Toaster />` component is rendered in `App.tsx` with `position="top-right"`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="700pt" height="700pt" version="1.1" viewBox="0 0 700 700" xmlns="http://www.w3.org/2000/svg">
<path d="m405.89 352.77c0 30.797-25.02 55.762-55.887 55.762s-55.887-24.965-55.887-55.762c0-30.797 25.02-55.762 55.887-55.762s55.887 24.965 55.887 55.762z"/>
<path d="m333.07 352.77h33.871v297.37h-33.871z"/>
<path d="m412.71 495.07-13.648-30.926c44.27-19.438 72.879-63.152 72.879-111.37 0-67.082-54.699-121.65-121.94-121.65-67.242 0-121.94 54.57-121.94 121.65 0 48.215 28.609 91.93 72.879 111.37l-13.648 30.926c-56.547-24.844-93.094-80.695-93.094-142.3 0-85.715 69.887-155.44 155.8-155.44 85.918 0 155.8 69.727 155.8 155.44-0.003906 61.594-36.551 117.46-93.094 142.3z"/>
<path d="m410.17 581.6-8.5742-32.691c89.277-23.309 151.63-103.96 151.63-196.15 0-111.8-91.168-202.75-203.22-202.75-112.06 0.003907-203.23 90.961-203.23 202.77 0 92.184 62.348 172.83 151.63 196.15l-8.5742 32.691c-104.18-27.195-176.93-121.3-176.93-228.83 0-130.43 106.36-236.54 237.1-236.54 130.73 0 237.1 106.12 237.1 236.54-0.003906 107.52-72.754 201.62-176.93 228.82z"/>
<path d="m409.05 661.5-6.3125-33.199c132.34-25.047 228.39-140.93 228.39-275.53 0-154.66-126.12-280.48-281.13-280.48-155 0-281.13 125.82-281.13 280.48 0 134.6 96.055 250.48 228.39 275.53l-6.3164 33.199c-148.3-28.07-255.95-157.91-255.95-308.73 0-173.29 141.32-314.27 315-314.27s315 140.98 315 314.27c0 150.81-107.65 280.66-255.95 308.73z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<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" />
<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 type="module" crossorigin src="/assets/index-CQVFPi-8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bg6UtK9Z.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,21 +0,0 @@
{
"name": "RemoteTerm for MeshCore",
"short_name": "RemoteTerm",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -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>

View 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.

View 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.

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
export {};
//# sourceMappingURL=cli.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}

View 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

File diff suppressed because one or more lines are too long

View 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

View 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"}

View 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

View 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"}

View 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

View 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"}

View 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

File diff suppressed because one or more lines are too long

View 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

View 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"}

View 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

View 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"}

View 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

Some files were not shown because too many files have changed in this diff Show More