370 Commits

Author SHA1 Message Date
Jack Kingsman
cee7103ec6 Don't log on missed login ack and don't make standalone contacts for repeater users 2026-03-19 20:26:10 -07:00
Jack Kingsman
d05312c157 Add password-remember + warning on save 2026-03-19 20:10:59 -07:00
Jack Kingsman
5b166c4b66 Add room server 2026-03-19 19:22:40 -07:00
Jack Kingsman
dbe2915635 Use metric by default 2026-03-19 17:56:04 -07:00
Jack Kingsman
2337d7b592 Remove Apprise duplicate names. Closes #88. 2026-03-19 17:44:51 -07:00
Jack Kingsman
62080424bb Multi-ack. Closes #81. 2026-03-19 17:30:34 -07:00
Jack Kingsman
1ae76848fe Improve test coverage 2026-03-19 17:19:35 -07:00
Jack Kingsman
45ed430580 Allow favorites to be sorted. Closes #91. 2026-03-19 17:05:34 -07:00
Jack Kingsman
5f8ce16855 Fix spacing around byte display on packet detail. Closes #93. 2026-03-19 16:58:27 -07:00
Jack Kingsman
b79249c4a0 Add more realistic hop stats display 2026-03-19 16:49:06 -07:00
Jack Kingsman
85d1a940dc Update meshcore for three byte path failures 2026-03-19 09:57:06 -07:00
Jack Kingsman
b85d451e26 Add packet feed clickable packet inspection. Closes #75 again. 2026-03-19 09:49:14 -07:00
Jack Kingsman
41a297c944 GIVE ME SMOOTS. Closes #87. 2026-03-18 22:43:34 -07:00
Jack Kingsman
41d64d86d4 Expand docker version testing coverage. Closes #84. 2026-03-18 22:09:44 -07:00
Jack Kingsman
bd336e3ee2 Add fancy metrics view for packet feed. Closes #75. 2026-03-18 22:01:10 -07:00
Jack Kingsman
cf585cdf87 Unread DMs are always red. Closes #86. 2026-03-18 21:05:40 -07:00
Jack Kingsman
417a583696 Use proper version formatting. Closes #70. 2026-03-18 20:50:56 -07:00
Jack Kingsman
541dba6a75 Fix migration to not import historical advert path 2026-03-19 03:45:51 +00:00
Jack Kingsman
720b8be64f Add e2e test 2026-03-19 03:45:51 +00:00
Jack Kingsman
2b5083e889 Doc updates 2026-03-19 03:45:51 +00:00
Jack Kingsman
5975006cf7 Dupe code cleanup 2026-03-19 03:45:51 +00:00
Jack Kingsman
69e09378f5 Pass 1 on PATH integration 2026-03-19 03:45:51 +00:00
Jack Kingsman
b832239e22 Add zero-hop impulse advert. Closes #83. 2026-03-18 19:59:08 -07:00
Jack Kingsman
d8e22ef4af Stop autofocus stealing of the cracker panel. Closes #80. 2026-03-18 17:53:42 -07:00
Jack Kingsman
ffc5d75a58 Use the actual remoteterm version for the client_version tag so letsmesh rollups work better 2026-03-18 17:46:10 -07:00
Jack Kingsman
350c85ca6d Behave better around DM dedupe/storage. Closes #77. 2026-03-18 17:40:11 -07:00
Jack Kingsman
4d5f0087cc Fix sidebar ordering for contacts by advert. Closes #69. 2026-03-17 21:04:57 -07:00
Jack Kingsman
e33bc553f5 improve MQTT error bubble up and massage communitymqtt + debug etc. for version management. Closes #70 2026-03-17 20:33:09 -07:00
Jack Kingsman
020acbda02 Do better DM retry to align with stndard firmware retry (but so that we can track the acks). Closes #73. 2026-03-17 18:12:07 -07:00
Jack Kingsman
d5b8f7d462 Prevent contact status bar jitter. Closes #68. 2026-03-17 16:28:05 -07:00
Jack Kingsman
bc16d804e9 Log the node time on startup 2026-03-16 22:44:53 -07:00
Jack Kingsman
a0459edf62 Add clowntown clock rollover trick 2026-03-16 22:37:13 -07:00
Jack Kingsman
86170766eb Better no-connection errors 2026-03-16 22:17:54 -07:00
Jack Kingsman
33e1b527bd Don't force save-prompt on unedited integrations 2026-03-16 21:47:04 -07:00
Jack Kingsman
23f9bd216c Add pre-filled letsmesh/meshrank MQTT 2026-03-16 21:43:45 -07:00
Jack Kingsman
35b592d2a7 Auto-reset node if clock is too far ahead to change 2026-03-16 21:24:07 -07:00
Jack Kingsman
c215aedc0d Let the radio settings pane open but not be used, rather than disabled 2026-03-16 20:30:34 -07:00
Jack Kingsman
9cd567895b Update the docs 2026-03-16 19:29:09 -07:00
Jack Kingsman
c469633a30 Serialize radio disconnect in a lock 2026-03-16 19:25:00 -07:00
Jack Kingsman
f8f0b3a8cf Updating changelog + build for 3.4.1 2026-03-16 18:43:34 -07:00
Jack Kingsman
47276dcb6c Improve handling of version info in pre-built bundles 2026-03-16 18:41:19 -07:00
Jack Kingsman
9c06ed62a4 Fix reopen-last behavior with new settings hash 2026-03-16 18:17:21 -07:00
Jack Kingsman
e19a8d3395 Update our docs and README.md. Closes #65. 2026-03-16 18:14:31 -07:00
Jack Kingsman
b68bfc41d6 Use better behavior on disconnected radio and allow deeplinking into settings. Closes #66. 2026-03-16 17:46:12 -07:00
Jack Kingsman
ffb5fa51c1 Finish frontend phase 3 2026-03-16 17:32:27 -07:00
Jack Kingsman
0e4828bf72 Frontend optimization part 2 2026-03-16 17:32:27 -07:00
Jack Kingsman
a5d9632a67 Phase 1 of frontend fixup 2026-03-16 17:32:27 -07:00
Jack Kingsman
24747ecd17 Unify our DM ingest 2026-03-16 17:32:27 -07:00
Jack Kingsman
dbb8dd4c43 Updating changelog + build for 3.4.0 2026-03-16 15:41:43 -07:00
Jack Kingsman
6c003069d4 Move to pre-built frontend on release only. Closes #62 2026-03-16 15:40:01 -07:00
Jack Kingsman
ea5ba3b2a3 Add radio model and stats display. Closes #64 2026-03-16 15:29:21 -07:00
Jack Kingsman
58b34a6a2f Make pagination requests abortable 2026-03-16 15:18:38 -07:00
Jack Kingsman
4277e0c924 Clear keys on radio disconnect and add better error for channel send non-radio response 2026-03-16 15:11:02 -07:00
Jack Kingsman
2f562ce682 Don't reconcile mid history view 2026-03-16 15:03:55 -07:00
Jack Kingsman
370ff115b4 Fix DM collapse on same second send 2026-03-16 14:53:48 -07:00
Jack Kingsman
04733b6a02 Use advert position if we don't have a from-repeater-stats lat/lon 2026-03-16 14:33:04 -07:00
Jack Kingsman
749fb43fd0 Ditch garbage data ingest for lat/lon and extend map. Closes #63 2026-03-16 14:24:58 -07:00
Jack Kingsman
8d7d926762 Fix repeater clock drift-drift on nav-away-come-back 2026-03-16 11:32:40 -07:00
Jack Kingsman
c809dad05d Polish off all our gross edges around frontend/backend Public name management 2026-03-15 18:20:43 -07:00
Jack Kingsman
c76f230c9f Reduce memo thrash on map update 2026-03-15 18:07:30 -07:00
Jack Kingsman
226dc4f59e use server time for advert freshness 2026-03-15 17:55:47 -07:00
Jack Kingsman
3f50a2ef07 fix e2e sort order test 2026-03-15 17:15:14 -07:00
Jack Kingsman
4a7ea9eb29 Always valodate fanout configs on save 2026-03-15 16:27:43 -07:00
Jack Kingsman
29368961fc Tweak send no-response handling 2026-03-15 16:12:17 -07:00
Jack Kingsman
7cb84ea6c7 Fix sidebar sort order. Closes #61 2026-03-15 16:02:09 -07:00
Jack Kingsman
0b1a19164a Fix typo and add better ops for decrypt API calls 2026-03-15 15:47:09 -07:00
Jack Kingsman
cf1a55e258 Add prebuilt frontend 2026-03-14 23:05:57 -07:00
Jack Kingsman
0881998e5b Overhaul repeater interaction to better deal with login failure clearly 2026-03-14 22:58:14 -07:00
Jack Kingsman
ac65943263 Updating changelog + build for 3.3.0 2026-03-13 22:25:47 -07:00
Jack Kingsman
04b324b711 Don't treat matched-prefix DMs as an ack (as it is an echo-ack for channel messages, not DMs) 2026-03-13 22:17:27 -07:00
Jack Kingsman
5512f9e677 Fix last-advert selection logic for path recency 2026-03-13 22:11:02 -07:00
Jack Kingsman
b4962d39f0 Fix up resend logic to be cleaner 2026-03-13 22:07:16 -07:00
Jack Kingsman
39a687da58 Forward whole message to FE on resend so the browser updates 2026-03-13 21:57:19 -07:00
Jack Kingsman
f41c7756d3 Prevent same-second outgoing collision now that we can send faster.
Also add pending ack tracking
2026-03-13 21:43:50 -07:00
Jack Kingsman
bafea6a172 Fix blocking on DMs (again, but right this time) 2026-03-13 21:10:50 -07:00
Jack Kingsman
68f05075ca Modal-ify the room region override 2026-03-13 18:11:18 -07:00
Jack Kingsman
adfb8c930c Move routing override to modal 2026-03-13 18:04:49 -07:00
Jack Kingsman
1299a301c1 Add route discovery 2026-03-13 17:55:17 -07:00
Jack Kingsman
3a4ea8022b Add local node discovery 2026-03-13 17:25:28 -07:00
Jack Kingsman
bd19015693 Don't suggest npm ci 2026-03-13 14:46:55 -07:00
Jack Kingsman
cb9c9ae289 Docs updates 2026-03-13 11:18:11 -07:00
Jack Kingsman
2369e69e0a Catch channel cache issues on set_channel failure during eviction 2026-03-13 11:09:23 -07:00
Jack Kingsman
9c2b6f0744 Add fallback polling message persistence for channel messages 2026-03-13 11:05:49 -07:00
Jack Kingsman
70d28e53a9 Fix self-node snapping on node addition 2026-03-13 10:42:36 -07:00
Jack Kingsman
96d8d1dc64 Be more strict with SQS region 2026-03-12 23:57:13 -07:00
Jack Kingsman
a7ff041a48 Drop out channel hash helper 2026-03-12 23:57:13 -07:00
Jack Kingsman
5a580b9c01 Tighten up error phasing 2026-03-12 23:57:13 -07:00
Jack Kingsman
0834414ba4 Remove redundant channel listing 2026-03-12 23:57:13 -07:00
Jack Kingsman
df538b3aaf Parallelize docker_ci 2026-03-12 23:57:13 -07:00
Jack Kingsman
2710cafb21 Add health endpoint 2026-03-12 23:57:13 -07:00
Jack Kingsman
338f632514 Remove unused endpoint and fix stale slot retry problems 2026-03-12 23:57:13 -07:00
Jack Kingsman
7e1f941760 Add documentation and force-lock-acquisition mode for channel management 2026-03-12 23:57:13 -07:00
Jack Kingsman
87ea2b4675 LRU-based parallel channel storage 2026-03-12 23:57:13 -07:00
Jack Kingsman
5c85a432c8 Phase 1 of manual channel management 2026-03-12 23:57:13 -07:00
Jack Kingsman
22ca5410ee Fix up unread bugs 2026-03-12 22:00:18 -07:00
Jack Kingsman
276e0e09b3 Show dismiss 'X' on the Jump to Unread 2026-03-12 20:23:38 -07:00
Jack Kingsman
1c57e35ba5 Don't collapse ambiguous senders to imply an indirect link betwen repeaters 2026-03-12 20:13:45 -07:00
Jack Kingsman
358589bd66 Cull a bunch of unused functions 2026-03-12 18:12:27 -07:00
Jack Kingsman
74c13d194c Fix message dual render and get the jump to unread link out of the way on visible unread boundaries. Closes #57. 2026-03-12 16:31:47 -07:00
Jack Kingsman
07fd88a4d6 Make repeater neighbor display need a GPS fix to show map + distance, and fetch before display. Closes #58. 2026-03-12 16:18:52 -07:00
Jack Kingsman
07934093e6 Don't force-insert a node with unknown relationships just because they are the marked recipient of a DM. Closes #44. 2026-03-12 14:30:26 -07:00
Jack Kingsman
3ee4f9d7a2 Do some same name ambiguous + known sibling collapse 2026-03-12 13:10:57 -07:00
Jack Kingsman
b81f6ef89e Visualizer overhaul 2026-03-12 12:56:59 -07:00
Jack Kingsman
489950a2f7 Use dashed lines for collapsed ambiguous repeater paths. Closes #44. 2026-03-12 12:11:17 -07:00
Jack Kingsman
08e00373aa Updating changelog + build for 3.2.0 2026-03-12 11:55:56 -07:00
Jack Kingsman
2f0d35748a Deepend contrast on map colors. Fixes #54 2026-03-12 11:39:42 -07:00
Jack Kingsman
7db2974481 Fix bot kwargs and scoot over unread button to middle 2026-03-12 10:58:38 -07:00
Jack Kingsman
0a20929df6 Add conversation unread marker and jump-to-unread button 2026-03-12 10:54:25 -07:00
Jack Kingsman
30f6f95d8e Add path_bytes_per_hop to bot kwargs and encourage kwargs-only going forward 2026-03-12 10:32:33 -07:00
Jack Kingsman
9e8cf56b31 Be clearer about reality of location inclusion. Closes #53 2026-03-12 10:00:00 -07:00
Jack Kingsman
fb535298be Fix up search pane and header key alignment regression 2026-03-12 00:35:05 -07:00
Jack Kingsman
1f2903fc2d Add better preview pane and tweak some themes for contrast 2026-03-12 00:06:33 -07:00
Jack Kingsman
6466a5c355 Add node GPS enablement + sourcing. Closes #53. 2026-03-11 22:42:41 -07:00
Jack Kingsman
f8e88b3737 Docs tune up 2026-03-11 22:05:59 -07:00
Jack Kingsman
a13e241636 Other connected clients get new chans over WS 2026-03-11 22:02:05 -07:00
Jack Kingsman
8c1a58b293 Don't use a stale MC instance 2026-03-11 21:57:59 -07:00
Jack Kingsman
bf53e8a4cb Clarify message for fanout 2026-03-11 21:51:22 -07:00
Jack Kingsman
c6cd209192 Change direct trace icon 2026-03-11 21:40:09 -07:00
Jack Kingsman
e5c7ebb388 Tweak the win95 theme coloring a bit 2026-03-11 21:05:55 -07:00
Jack Kingsman
e37632de3f Add dedupe bug the agents keep getting hung up on to errata 2026-03-11 20:58:29 -07:00
Jack Kingsman
d36c5e3e32 Deal with reconciliation conflict from colliding WS fetches 2026-03-11 20:52:48 -07:00
Jack Kingsman
bc7506b0d9 Some misc frontend cleanup grossness 2026-03-11 20:49:37 -07:00
Jack Kingsman
38c7277c9d More careful guards around channel message key matching on collision 2026-03-11 20:41:31 -07:00
Jack Kingsman
20d0bd92bb Oh god, so much code for such a minor flow. Ambiguous sender manually fetched prefix DMs are now visible. 2026-03-11 20:38:41 -07:00
Jack Kingsman
e0df30b5f0 AGENTS.md fixups 2026-03-11 19:44:29 -07:00
Jack Kingsman
83635845b6 Don't sleep in the exception handler 2026-03-11 19:32:54 -07:00
Jack Kingsman
2e705538fd Abort serch requests on unmount 2026-03-11 19:26:27 -07:00
Jack Kingsman
4363fd2a73 Track message reconciliation and don't fire on stale returns 2026-03-11 19:20:38 -07:00
Jack Kingsman
5bd3205de5 Stop channs with blocked senders bothering the unread count 2026-03-11 19:11:53 -07:00
Jack Kingsman
bcde3bd9d5 Don't construct synthetic objects 2026-03-11 19:04:57 -07:00
Jack Kingsman
15a8c637e4 Do our own tracking for contact-on-radio with more correct return state 2026-03-11 19:01:14 -07:00
Jack Kingsman
d38efc0421 Add warning on search for user-key linkage unreliability 2026-03-11 18:45:03 -07:00
Jack Kingsman
b311c406da Updating changelog + build for 3.1.1 2026-03-11 18:24:41 -07:00
Jack Kingsman
b5e9e4d04c Fix tag notes 2026-03-11 18:23:20 -07:00
Jack Kingsman
ce87dd9376 Updating changelog + build for 3.1.0 2026-03-11 18:20:31 -07:00
Jack Kingsman
5273d9139d Use newer workflow steps 2026-03-11 18:19:04 -07:00
Jack Kingsman
04ac3d6ed4 Drop out meshcore_py's autoreconnect logic on connection disable 2026-03-11 18:12:11 -07:00
Jack Kingsman
1a1d3059db Split up runs 2026-03-11 18:06:40 -07:00
Jack Kingsman
633510b7de Bring back in package-lock.json 2026-03-11 17:44:55 -07:00
Jack Kingsman
7f4c1e94fd Add github workflows 2026-03-11 17:37:57 -07:00
Jack Kingsman
a06fefb34e New themes 2026-03-11 17:28:12 -07:00
Jack Kingsman
4e0b6a49b0 Add ability to pause radio connection (closes #51) 2026-03-11 17:17:03 -07:00
Jack Kingsman
e993009782 True up some UX inconsistencies and have a theme preview pane 2026-03-11 17:03:43 -07:00
Jack Kingsman
ad7028e508 Add better search management and operators + contact search quick link 2026-03-11 16:56:09 -07:00
Jack Kingsman
ce9bbd1059 Better clarity on sidebar search 2026-03-11 16:22:02 -07:00
Jack Kingsman
0c35601af3 Enrich contact no-key info pane with first-in-use date 2026-03-11 16:19:10 -07:00
Jack Kingsman
93369f8d64 Enrich names-based contact pane a bit 2026-03-11 15:57:29 -07:00
Jack Kingsman
e7d1f28076 Add SQS fanout 2026-03-11 14:17:08 -07:00
Jack Kingsman
472b4a5ed2 Better logging output 2026-03-11 13:40:48 -07:00
Jack Kingsman
314e4c7fff True up regional routing icon style 2026-03-11 10:04:01 -07:00
Jack Kingsman
528a94d2bd Add basic auth 2026-03-11 10:02:02 -07:00
Jack Kingsman
fa1c086f5f Updating changelog + build for 3.0.0 2026-03-10 21:41:04 -07:00
Jack Kingsman
d8bb747152 Reorder themes 2026-03-10 21:27:05 -07:00
Jack Kingsman
18a465fde8 Fix ordering 2026-03-10 21:06:50 -07:00
Jack Kingsman
c52e00d2b7 Merge pull request #50 from jkingsman/notifications
Notifications
2026-03-10 20:49:12 -07:00
Jack Kingsman
e17d1ba4b4 Move search bar to top level 2026-03-10 19:56:10 -07:00
Jack Kingsman
48a49ce48d Add some new themes 2026-03-10 19:54:06 -07:00
Jack Kingsman
9d1676818f Add lagoon pop 2026-03-10 19:51:10 -07:00
Jack Kingsman
b5edd00220 Tweak light mode tools color and icon state 2026-03-10 19:36:04 -07:00
Jack Kingsman
d3a7b7ce07 Add light mode toggle 2026-03-10 19:32:22 -07:00
Jack Kingsman
42ca242ee1 Update override badge for region routing 2026-03-10 19:26:31 -07:00
Jack Kingsman
3e7e0669c5 Add bell icon and use better notif icon 2026-03-10 19:04:52 -07:00
Jack Kingsman
bee273ab56 Add notifications 2026-03-10 19:03:52 -07:00
Jack Kingsman
1842bcf43e Add new icon size + crush PNGs 2026-03-10 19:03:34 -07:00
Jack Kingsman
7c68973e30 Icon overhaul 2026-03-10 17:43:15 -07:00
Jack Kingsman
c9ede1f71f Clearer about advertiser repeat button 2026-03-10 15:49:28 -07:00
Jack Kingsman
42e9628d98 Fix clock sync command 2026-03-10 15:46:34 -07:00
Jack Kingsman
1bf760121d Preserve repeater values when browsing away 2026-03-10 15:40:26 -07:00
Jack Kingsman
bb4a601788 Coerce uvicorn logging to better format 2026-03-10 14:58:44 -07:00
Jack Kingsman
d0ed3484ce Add hourly sync and crow loudly if it finds a discrepancy 2026-03-10 14:47:18 -07:00
Jack Kingsman
738e0b9815 Don't load full right away 2026-03-10 14:39:40 -07:00
Jack Kingsman
97997e23e8 Drop frequency of contact sync task, make standard polling opt-in only 2026-03-10 14:04:51 -07:00
Jack Kingsman
eaee66f836 Add timestamps to logs and stop regen'ing licenses every time 2026-03-10 13:08:26 -07:00
Jack Kingsman
9a99d3f17e Codex Refactor -- Make things more manageable and LLM friendly 2026-03-10 12:26:30 -07:00
Jack Kingsman
73e717fbd8 Fix Load All button height 2026-03-10 09:41:23 -07:00
Jack Kingsman
dc87fa42b2 Update AGENTS.md 2026-03-10 00:00:57 -07:00
Jack Kingsman
f650e0ab34 Make all scripts executable 2026-03-09 23:55:17 -07:00
Jack Kingsman
39b745f8b0 Compactify some things for LLM wins 2026-03-09 23:53:19 -07:00
Jack Kingsman
18e1408292 Be better about DB insertion shape 2026-03-09 23:42:46 -07:00
Jack Kingsman
3e941a5b20 remove radio dependency fallback shim 2026-03-09 23:29:25 -07:00
Jack Kingsman
a000fc88a5 make radio router use runtime seam only 2026-03-09 23:22:56 -07:00
Jack Kingsman
def7c8e29e route radio sync through radio runtime 2026-03-09 23:16:17 -07:00
Jack Kingsman
9388e1f506 route startup and fanout through radio runtime 2026-03-09 23:11:57 -07:00
Jack Kingsman
81bdfe09fa extract radio runtime seam 2026-03-09 23:07:34 -07:00
Jack Kingsman
5e94b14b45 Refactor visualizer 2026-03-09 22:20:21 -07:00
Jack Kingsman
c3f1a43a80 Be more gentle with frontend typing + go back to fire-and-forget for cracked room creation 2026-03-09 21:51:07 -07:00
Jack Kingsman
3316f00271 extract app shell prop assembly 2026-03-09 21:07:56 -07:00
Jack Kingsman
319b84455b extract conversation navigation state 2026-03-09 20:59:52 -07:00
Jack Kingsman
f107dce920 extract frontend app shell 2026-03-09 20:23:24 -07:00
Jack Kingsman
ec5b9663b2 Brief interlude -- fix corrupt packet message display 2026-03-09 20:11:13 -07:00
Jack Kingsman
19d7c3c98c extract conversation pane component 2026-03-09 19:41:03 -07:00
Jack Kingsman
ae0ef90fe2 extract conversation timeline hook 2026-03-09 19:12:26 -07:00
Jack Kingsman
56e5e0d278 extract frontend conversation actions hook 2026-03-09 18:37:06 -07:00
Jack Kingsman
5d509a88d9 extract frontend realtime state hook 2026-03-09 18:27:01 -07:00
Jack Kingsman
946006bd7f extract radio command service 2026-03-09 18:13:18 -07:00
Jack Kingsman
344cee5508 extract radio lifecycle service 2026-03-09 18:02:58 -07:00
Jack Kingsman
0d671f361d extract message send service 2026-03-09 17:54:44 -07:00
Jack Kingsman
2d781cad56 add typed websocket event contracts 2026-03-09 17:47:31 -07:00
Jack Kingsman
088dcb39d6 extract contact reconciliation service 2026-03-09 17:32:43 -07:00
Jack Kingsman
b1e3e71b68 extract dm ack tracker service 2026-03-09 17:03:07 -07:00
Jack Kingsman
557af55ee8 extract backend message lifecycle service 2026-03-09 16:56:23 -07:00
Jack Kingsman
9421c10e8f Refetch channels on reconnect and fix up count-change refresh guard 2026-03-09 16:44:39 -07:00
Jack Kingsman
b157ee14e4 Add background-hash-mark addition for region routing
Per https://buymeacoffee.com/ripplebiz/region-filtering:

> After some discussions, and that there is some confusion
around #channels and #regions, it's been decided to drop
the requirement to have the '#' prefix. So, region names
will just be plain alphanumeric (and '-'), with no # prefix.

> For backwards compatibility, the names will internally have
a '#' prepended, but for all client GUI's and command lines,
you generally won't see mention of '#' prefixes. The next
firmware release (v1.12.0) and subsequent Ripple firmware
and Liam's app will have modified UI to remove the '#' requirement.

So, silently add, but don't duplicate, for users who have already
added hashmarks.
2026-03-09 15:24:23 -07:00
Jack Kingsman
e03ddcaaa7 Improve correctness of regional traffic repeats 2026-03-09 15:03:18 -07:00
Jack Kingsman
6832516b40 Add improved note about region entry 2026-03-09 14:57:45 -07:00
Jack Kingsman
5bfd9f4af2 Use updated meshcore-decoder library with TRACE patch and fixup frontend routing display on packet list 2026-03-09 12:45:51 -07:00
Jack Kingsman
463a0c9084 Add bolder coloring for mentions in rollups and always use bold on DMs 2026-03-09 11:57:30 -07:00
Jack Kingsman
811c7e7349 Add regional channel routing (closes #42) 2026-03-09 11:09:31 -07:00
Jack Kingsman
0c5b37c07c Add custom pathing (closes #45) 2026-03-09 10:26:01 -07:00
Jack Kingsman
7e384c12bb Fix trace packet handling (closes #44) 2026-03-09 09:23:35 -07:00
Jack Kingsman
48bc8c6337 Improve bot error bubble uo along with a few other spots 2026-03-09 01:18:41 -07:00
Jack Kingsman
c3d7b8f79a Improve bot error bubble uo along with a few other spots 2026-03-09 00:41:07 -07:00
Jack Kingsman
b5c4413e63 Bump node requirement to 20+ 2026-03-08 23:00:08 -07:00
Jack Kingsman
9fbdbaa174 Updating changelog + build for 2.7.9 2026-03-08 22:18:59 -07:00
Jack Kingsman
e99e522573 Fix clipping on integration add drop down 2026-03-08 22:17:32 -07:00
Jack Kingsman
9d806c608b Add contact normalization rather than loading the packed path bytes 2026-03-08 21:01:01 -07:00
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
447 changed files with 73080 additions and 12113 deletions

View File

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

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
frontend/prebuilt/** -diff

74
.github/workflows/all-quality.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: All Quality
on:
push:
pull_request:
jobs:
backend-checks:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install backend dependencies
run: uv sync --dev
- name: Backend lint
run: uv run ruff check app/ tests/
- name: Backend format check
run: uv run ruff format --check app/ tests/
- name: Backend typecheck
run: uv run pyright app/
- name: Backend tests
run: PYTHONPATH=. uv run pytest tests/ -v
frontend-checks:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Frontend lint
run: npm run lint
working-directory: frontend
- name: Frontend format check
run: npm run format:check
working-directory: frontend
- name: Frontend tests
run: npm run test:run
working-directory: frontend
- name: Frontend build
run: npm run build
working-directory: frontend

5
.gitignore vendored
View File

@@ -12,9 +12,12 @@ frontend/test-results/
# Frontend build output (built from source by end users)
frontend/dist/
frontend/package-lock.json
frontend/prebuilt/
frontend/.eslintcache
# Release artifacts
remoteterm-prebuilt-frontend-v*.zip
# reference libraries
references/

196
AGENTS.md
View File

@@ -10,43 +10,48 @@ If instructed to "run all tests" or "get ready for a commit" or other summative,
./scripts/all_quality.sh
```
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend in parallel. All checks must pass green.
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
## Overview
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over Serial, TCP, or BLE and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
**For detailed component documentation, see:**
**For detailed component documentation, see these primary AGENTS.md files:**
- `app/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption)
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
- `frontend/src/components/AGENTS.md` - Frontend visualizer feature (a particularly complex and long force-directed graph visualizer component; can skip this file unless you're working on that feature)
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (React)
│ Frontend (React) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ CrackerPanel (global collapsible, WebGPU cracking) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ useWebSocket ←──── Real-time updates
│ │
│ api.ts ←──── REST API calls
└───────────────────────────┼─────────────────────────────────────
│ │ │
│ useWebSocket ←──── Real-time updates │
│ │ │
│ api.ts ←──── REST API calls │
└───────────────────────────┼─────────────────────────────────────┘
│ HTTP + WebSocket (/api/*)
┌───────────────────────────┼──────────────────────────────────────┐
│ Backend (FastAPI) │
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │
│ ↓ ───────────
│ ┌──────────────────────────────────────────────────────────┐
│ │ RadioManager + Event Handlers │ │
└──────────────────────────────────────────────────────────┘
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────┐
│ │ Routers │→ │ Services │→ │ Repositories │→ │ SQLite DB │ │
│ └──────────┘ └──────────┘ └──────────────┘ └────────────┘ │
│ ↓ ───────────
│ ┌──────────────────────────┐ └──────────────→ │ WebSocket │
│ │ Radio runtime seam + Manager │
│ RadioManager lifecycle │ └───────────┘
│ │ / event adapters │ │
│ └──────────────────────────┘ │
└───────────────────────────┼──────────────────────────────────────┘
│ Serial / TCP / BLE
┌──────┴──────┐
@@ -72,13 +77,15 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
- Raw packet feed — a debug/observation tool ("radio aquarium"); interesting to watch or copy packets from, but not critical infrastructure
- Map view — visual display of node locations from advertisements
- Network visualizer — force-directed graph of mesh topology
- Bot system — automated message responses
- Fanout integrations (MQTT, bots, webhooks, Apprise, SQS) — see `app/fanout/AGENTS_fanout.md`
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
## Error Handling Philosophy
**Background tasks** (WebSocket broadcasts, periodic sync, contact auto-loading, etc.) use fire-and-forget `asyncio.create_task`. Exceptions in these tasks are logged to the backend logs, which is sufficient for debugging. There is no need to track task references or add done-callbacks purely for error visibility. If there's a convenient way to bubble an error to the frontend (e.g., via `broadcast_error` for user-actionable problems), do so, but this is minor and best-effort.
Radio startup/setup is one place where that frontend bubbling is intentional: if post-connect setup hangs past its timeout, the backend both logs the failure and pushes a toast instructing the operator to reboot the radio and restart the server.
## Key Design Principles
1. **Store-and-serve**: Backend stores all packets even when no client is connected
@@ -88,13 +95,22 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
5. **Offline-capable**: Radio operates independently; server syncs when connected
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
## Code Ethos
- Prefer fewer, stronger modules over many tiny wrapper files.
- Split code only when the new module owns a real invariant, workflow, or contract.
- Avoid "enterprise" indirection layers whose main job is forwarding, renaming, or prop bundling.
- For this repo, "locally dense but semantically obvious" is better than context scattered across many files.
- Use typed contracts at important boundaries such as API payloads, WebSocket events, and repository writes.
- Refactors should be behavior-preserving slices with tests around the moved seam, not aesthetic reshuffles.
## Intentional Security Design Decisions
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this.
2. **Minimal optional access control only**: The app has no user accounts, sessions, authorization model, or per-feature permissions. Operators may optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but this is only a coarse gate and still requires HTTPS plus a trusted network posture.
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
## Intentional Packet Handling Decision
@@ -104,14 +120,31 @@ Raw packet handling uses two identities by design:
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
Channel metadata updates may also fan out as `channel` WebSocket events (full `Channel` payload) so clients can reflect local-only channel state such as regional flood-scope overrides without a full refetch.
## Contact Advert Path Memory
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per contact in a dedicated table (`contact_advert_paths`).
- This is independent of raw-packet payload deduplication.
- Paths are keyed per contact + path, with `heard_count`, `first_seen`, and `last_seen`.
- Paths are keyed per contact + path + hop count, with `heard_count`, `first_seen`, and `last_seen`.
- Only the N most recent unique paths are retained per contact (currently 10).
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
- See `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
## Path Hash Modes
MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
## Data Flow
@@ -126,14 +159,25 @@ To improve repeater disambiguation in the network visualizer, the backend stores
1. User types message → clicks send
2. `api.sendChannelMessage()` → POST to backend
3. Backend calls `radio_manager.meshcore.commands.send_chan_msg()`
3. Backend route delegates to service-layer send orchestration, which acquires the radio lock and calls MeshCore commands
4. Message stored in database with `outgoing=true`
5. For direct messages: ACK tracked; for channel: repeat detection
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
- Non-final attempts use the effective route (`override > direct > flood`).
- Retry timing follows the radio's `suggested_timeout`.
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
### ACK and Repeat Detection
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
This message-layer echo/path handling is independent of raw-packet storage deduplication.
@@ -144,19 +188,19 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
.
├── app/ # FastAPI backend
│ ├── AGENTS.md # Backend documentation
│ ├── bot.py # Bot execution and outbound bot sends
│ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints
│ ├── services/ # Shared backend orchestration/domain services, including radio_runtime access seam
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings)
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout)
│ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption
│ ├── websocket.py # Real-time broadcasts
│ └── mqtt.py # Optional MQTT publisher
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── frontend/ # React frontend
│ ├── AGENTS.md # Frontend documentation
│ ├── src/
│ │ ├── App.tsx # Main component
│ │ ├── App.tsx # Frontend composition entry (hooks → AppShell)
│ │ ├── api.ts # REST client
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── components/
@@ -164,10 +208,13 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ...
│ └── vite.config.ts
├── scripts/
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (parallelized)
│ ├── publish.sh # Version bump, changelog, docker build & push
── deploy.sh # Deploy to production server
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
│ ├── all_quality.sh # Repo-standard autofix + validate gate
│ ├── collect_licenses.sh # Gather third-party license attributions
── e2e.sh # End-to-end test runner
│ └── publish.sh # Version bump, changelog, docker build & push
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
├── CONTRIBUTING.md # Contributor workflow and testing guidance
├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime)
└── pyproject.toml # Python dependencies
@@ -212,7 +259,7 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
Access at `http://localhost:8000`. All API routes are prefixed with `/api`.
If `frontend/dist` (or `frontend/dist/index.html`) is missing, backend startup now logs an explicit error and continues serving API routes. In that case, frontend static routes are not mounted until a frontend build is present.
If `frontend/dist` is missing, the backend falls back to `frontend/prebuilt` when present (for example from the release zip artifact). If neither build directory is available, startup logs an explicit error and continues serving API routes without frontend static routes mounted.
## Testing
@@ -230,9 +277,16 @@ Key test files:
- `tests/test_api.py` - API endpoints, read state tracking
- `tests/test_migrations.py` - Database migration system
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
- `tests/test_real_crypto.py` - Real cryptographic operations
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
### Frontend (Vitest)
@@ -243,7 +297,7 @@ npm run test:run
### Before Completing Changes
**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds in parallel, catching type mismatches, breaking changes, and compilation errors.
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
## API Summary
@@ -251,46 +305,46 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Connection status |
| GET | `/api/radio/config` | Radio configuration |
| PATCH | `/api/radio/config` | Update name, location, radio params |
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
| GET | `/api/contacts` | List contacts |
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| DELETE | `/api/contacts/{public_key}` | Delete contact |
| POST | `/api/contacts/sync` | Pull from radio |
| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio |
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{public_key}/reset-path` | Reset contact path to flood |
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch radio settings via CLI |
| POST | `/api/contacts/{public_key}/repeater/node-info` | Fetch repeater name, location, and clock via CLI |
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}` | Get channel by key |
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
| POST | `/api/channels` | Create channel |
| DELETE | `/api/channels/{key}` | Delete channel |
| POST | `/api/channels/sync` | Pull from radio |
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
| GET | `/api/messages` | List with filters |
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
| POST | `/api/messages/direct` | Send direct message |
| POST | `/api/messages/channel` | Send channel message |
| POST | `/api/messages/channel/{message_id}/resend` | Resend an outgoing channel message (within 30 seconds) |
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
@@ -299,7 +353,13 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
| GET | `/api/statistics` | Aggregated mesh network statistics |
| WS | `/api/ws` | Real-time updates |
@@ -317,12 +377,14 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
- `1` - Client (regular node)
- `2` - Repeater
- `3` - Room
- `4` - Sensor
### Channel Keys
- Stored as 32-character hex string (TEXT PRIMARY KEY)
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
- Custom channels: User-provided or generated
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
### Message Types
@@ -344,22 +406,13 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
### MQTT Publishing
### Fanout Bus (MQTT, Bots, Webhooks, Apprise, SQS)
Optional MQTT integration forwards mesh events to an external broker for home automation, logging, or alerting. All MQTT config is stored in the database (`app_settings`), not env vars — configured from the Settings pane, no server restart needed.
All external integrations are managed through the fanout bus (`app/fanout/`). Each integration is a `FanoutModule` with scope-based event filtering, stored in the `fanout_configs` table and managed via `GET/POST/PATCH/DELETE /api/fanout`.
**Two independent toggles**: publish decrypted messages, publish raw packets.
`broadcast_event()` in `websocket.py` dispatches `message` and `raw_packet` events to the fanout manager. See `app/fanout/AGENTS_fanout.md` for full architecture details.
**Topic structure** (default prefix `meshcore`):
- `meshcore/dm:<contact_public_key>` — decrypted DM
- `meshcore/gm:<channel_key>` — decrypted channel message
- `meshcore/raw/dm:<contact_key>` — raw packet attributed to a DM contact
- `meshcore/raw/gm:<channel_key>` — raw packet attributed to a channel
- `meshcore/raw/unrouted` — raw packets that couldn't be attributed
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status`.
**Security**: MQTT password stored in plaintext in SQLite, consistent with the project's trusted-network design.
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
@@ -401,9 +454,28 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, and all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
**Transport mutual exclusivity:** Only one of `MESHCORE_SERIAL_PORT`, `MESHCORE_TCP_HOST`, or `MESHCORE_BLE_ADDRESS` may be set. If none are set, serial auto-detection is used.
## Errata & Known Non-Issues
### `meshcore_py` advert parsing can crash on malformed/truncated RF log packets
The vendored MeshCore Python reader's `LOG_DATA` advert path assumes the decoded advert payload always contains at least 101 bytes of advert body and reads the flags byte with `pk_buf.read(1)[0]` without a length guard. If a malformed or truncated RF log frame slips through, `MessageReader.handle_rx()` can fail with `IndexError: index out of range` from `meshcore/reader.py` while parsing payload type `0x04` (advert).
This does not indicate database corruption or a message-store bug. It is a parser-hardening gap in `meshcore_py`: the reader does not fully mirror firmware-side packet/path validation before attempting advert decode. The practical effect is usually a one-off asyncio task failure for that packet while later packets continue processing normally.
### Channel-message dedup intentionally treats same-name/same-text/same-second channel sends as indistinguishable because they are
Channel message storage deduplicates on `(type, conversation_key, text, sender_timestamp)`. Reviewers often flag this as "missing sender identity," but for channel messages the stored `text` already includes the displayed sender label (for example `Alice: hello`). That means two different users only collide when they produce the same rendered sender name, the same body text, and the same sender timestamp.
In that case, RemoteTerm usually does not have enough information to distinguish "two independent same-name sends" from "one message observed again as an echo/repeat." Without a reliable sender identity at ingest, treating those packets as the same message is an accepted limitation of the observable data model, not an obvious correctness bug.

View File

@@ -1,3 +1,178 @@
## [3.4.1] - 2026-03-16
Bugfix: Improve handling of version information on prebuilt bundles
Bugfix: Improve frontend usability on disconnected radio
Misc: Docs and readme updates
Misc: Overhaul DM ingest and frontend state handling
## [3.4.0] - 2026-03-16
Feature: Add radio model and stats display
Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
Bugfix: Misc. frontend performance and correctness fixes
Bugfix: Fix same-second same-content DM send collition
Bugfix: Discard clearly-wrong GPS data
Bugfix: Prevent repeater clock skew drift on page nav
Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
Misc: Don't permit invalid fanout configs to be saved ever`
## [3.3.0] - 2026-03-13
Feature: Use dashed lines to show collapsed ambiguous router results
Feature: Jump to unred
Feature: Local channel management to prevent need to reload channel every time
Feature: Debug endpoint
Feature: Force-singleton channel management
Feature: Local node discovery
Feature: Node routing discovery
Bugfix: Don't tell users to us npm ci
Bugfix: Fallback polling dm message persistence
Bugfix: All native-JS inputs are now modals
Bugfix: Same-second send collision resolution
Bugfix: Proper browser updates on resend
Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
Misc: Visualizer data layer overhaul for future map work
Misc: Parallelize docker tests
## [3.2.0] - 2026-03-12
Feature: Improve ambiguous-sender DM handling and visibility
Feature: Allow for toggling of node GPS broadcast
Feature: Add path width to bot and move example to full kwargs
Feature: Improve node map color contrast
Bugfix: More accurate tracking of contact data
Bugfix: Misc. frontend performance and bugfixes
Misc: Clearer warnings on user-key linkage
Misc: Documentation improvements
## [3.1.1] - 2026-03-11
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.1.0] - 2026-03-11
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.0.0] - 2026-03-10
Feature: Custom regions per-channel
Feature: Add custom contact pathing
Feature: Corrupt packets are more clear that they're corrupt
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
Feature: More consistent icons
Feature: Add per-channel local notifications
Feature: New themes
Feature: Massive codebase refactor and overhaul
Bugfix: Fix packet parsing for trace packets
Bugfix: Refetch channels on reconnect
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
Bugfix: Timestamps in logs
Bugfix: Correct wrong clock sync command
Misc: Improve bot error bubble up
Misc: Update to non-lib-included meshcore-decoder version
Misc: Revise refactors to be more LLM friendly
Misc: Fix script executability
Misc: Better logging format with timestamp
Misc: Repeater advert buttons separate flood and one-hop
Misc: Preserve repeater pane on navigation away
Misc: Clearer iconography and coloring for status bar buttons
Misc: Search bar to top bar
## [2.7.9] - 2026-03-08
Bugfix: Don't obscure new integration dropdown on session boundary
## [2.7.8] - 2026-03-08
## [2.7.8] - 2026-03-08
Bugfix: Improve frontend asset resolution and fixup the build/push script
## [2.7.1] - 2026-03-08
Bugfix: Fix historical DM packet length passing
Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
## [2.7.0] - 2026-03-08
Feature: Multibyte path support
Feature: Add multibyte statistics to statistics pane
Feature: Add path bittage to contact info pane
Feature: Put tools in a collapsible
## [2.6.1] - 2026-03-08
Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
## [2.6.0] - 2026-03-08
Feature: A11y improvements
Feature: New themes
Feature: Backfill channel sender identity when available
Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
Bugfix: Unreads now respect blocklist
Bugfix: Unreads can't accumulate on an open thread
Bugfix: Channel name in broadcasts
Bugfix: Add missing httpx dependency
Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
Misc: Improved button signage for app movement
Misc: Test, performance, and documentation improvements
## [2.5.0] - 2026-03-05
Feature: Far better accessibility across the app (with far to go)
Feature: Add community MQTT stats reporting, and improve over a few commits
Feature: Color schemes and misc. settings reorg
Feature: Add why-active to filtered nodes
Feature: Add channel and contact info box
Feature: Add contact blocking
Feature: Add potential repeater path map display
Feature: Add flood scoping/regions
Feature: Global message search
Feature: Fully safe bot disable
Feature: Add default #remoteterm channel (lol sorry I had to)
Feature: Custom recency pruning in visualizer
Bugfix: Be more cautious around null byte stripping
Bugfix: Clear channel-add interface on not-add-another
Bugfix: Add status/name/MQTT LWT
Bugfix: Channel deletion propagates over WS
Bugfix: Show map location for all nodes on link, not 7-day-limited
Bugfix: Hide private key channel keys by default
Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
Misc: Doc, changelog, and test improvements
Misc: Add, and remove, package lock (sorry Windows users)
Misc: Don't show mark all as read if not necessary
Misc: Fix stale closures and misc. frontend perf/correctness improvements
Misc: Add Windows startup notes
Misc: E2E expansion + improvement
Misc: Move around visualizer settings
## [2.4.0] - 2026-03-02
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
Misc: Build scripts and library attribution
Misc: Add sign of life to E2E tests
## [2.3.0] - 2026-03-01
Feature: Click path description to reset to flood
@@ -17,14 +192,14 @@ Feature: Contact info pane
Feature: Overhaul repeater interface
Bugfix: Misc. frontend rendering + perf improvements
Bugfix: Better behavior around radio locking and autofetch/polling
Bugifx: Clear channel name field on new-channel modal tab change
Bugfix: Clear channel name field on new-channel modal tab change
Bugfix: Repeater inforbox can scroll
Bugfix: Better handling of historical DM encrypts
Bugfix: Handle errors if returned in prefetch phase
Misc: Radio event response failure is logged/surfaced better
Misc: Improve test coverage and remove dead code
Misc: Documentatin and errata improvements
Misc: Databse storage optimization
Misc: Documentation and errata improvements
Misc: Database storage optimization
## [2.1.0] - 2026-02-23
@@ -64,11 +239,11 @@ Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
Feature: Frontend UX + log overhaul
Bugfix: Use contact object from DB for broadcast rather than handrolling
Bugfix: Fix our of order path WS messages voerwriting each other
Bugfix: Fix out of order path WS messages overwriting each other
Bugfix: Make broadcast timestamp match fallback logic used in storage code
Bugfix: Fir repeater command timestamp selection logic
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 sports
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
@@ -94,7 +269,7 @@ Feature: Upgrade the room finder to support two-word rooms
## [1.9.2] - 2026-02-12
Feature: Options dialog sucks less
Bugix: Move tests to isolated memory DB
Bugfix: Move tests to isolated memory DB
Bugfix: Mention case sensitivity
Bugfix: Stale header retention on settings page view
Bugfix: Non-isolated path writing
@@ -147,30 +322,13 @@ Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
## [1.8.0] - 2026-02-07
Feature: Single hop ping
Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
Bugfix: Better guarding around reconnection
Bugfix: Duplicate websocket connection fixes
Bugfix: Settings tab error cleanliness on tab swap
Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier)
Misc: Perf and locking improvements
Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
## [1.7.1] - 2026-02-03
Feature: Clickable hyperlinks
Bugfix: More consistent public key normalization
Bugfix: Use more reliable cursor paging
Bugfix: Fix null timestamp dedupe failure
Bugfix: More concistent prefix-based message claiming on key reciept
Bugfix: More consistent prefix-based message claiming on key receipt
Misc: Bot can respond to its own messages
Misc: Additional tests
Misc: Remove unneeded message dedupe logic

108
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,108 @@
# Contributing
## Guiding Principles
- In all your interactions with developers, maintainers, and users, be kind.
- Prefer small, comprehensible changes over large sweeping ones. Individual commits should be meaningful atomic chunks of work. Pull requests with many, many commits instead of a phased approach may be declined.
- Pull requests must be fully understood and explicitly endorsed by a human before merge. AI assistance is great, and this repo is optimized for it, but we keep quality by keeping our agents on track to write clear code, useful (not useless) tests, good architecture, and big-picture thinking.
- No pull request should introduce new failing lint, typecheck, test, or build results.
- Every pull request should have an associated issue or discussion thread; a brand new feature appearing first in a PR is an antipattern.
- No truly automated radio traffic. Bot replies are already the practical edge of what this project wants to automate; any kind of traffic that would be intervalized or automated is not what this project is about.
- No ingestion from the internet onto the mesh. This project is a radio client, not a bridge for outside traffic to enter the network. The mesh is strong because it is a radio mesh, not the internet with some weird wireless links.
## Local Development
### Backend
```bash
uv sync
uv run uvicorn app.main:app --reload
```
With an explicit serial port:
```bash
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
```
On Windows (PowerShell):
```powershell
uv sync
$env:MESHCORE_SERIAL_PORT="COM8"
uv run uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Run both the backend and `npm run dev` for hot-reloading frontend development.
## Quality Checks
Run the full quality suite before proposing or handing off code changes:
```bash
./scripts/all_quality.sh
```
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
If you need targeted commands while iterating:
```bash
# backend
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
uv run pyright app/
PYTHONPATH=. uv run pytest tests/ -v
# frontend
cd frontend
npm run lint:fix
npm run format
npm run test:run
npm run build
```
## E2E Testing
E2E coverage exists, but it is intentionally not part of the normal development path.
These tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly, an available autodetect-able radio, and a contact in the test database (which you can provide in `tests/e2e/.tmp/e2e-test.db` after an initial run). E2E tests are generally not necessary to run for normal development work.
```bash
cd tests/e2e
npx playwright test # headless
npx playwright test --headed # you can probably guess
```
## Pull Request Expectations
- Keep scope tight.
- Explain why the change is needed.
- Link the issue or discussion where the behavior was agreed on.
- Call out any follow-up work left intentionally undone.
- Do not treat code review as the place where the app's direction is first introduced or debated
## Notes For Agent-Assisted Work
Before making non-trivial changes, read:
- `./AGENTS.md`
- `./app/AGENTS.md`
- `./frontend/AGENTS.md`
Read these only when working in those areas:
- `./app/fanout/AGENTS_fanout.md`
- `./frontend/src/components/visualizer/AGENTS_packet_visualizer.md`
- Agent output is welcome, but human review is mandatory.
- Agents should start with the AGENTS files above before making architectural changes.
- If a change touches advanced areas like fanout or the visualizer, read the area-specific AGENTS file before editing.

View File

@@ -5,7 +5,7 @@ ARG COMMIT_HASH=unknown
WORKDIR /build
COPY frontend/package*.json ./
COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./
RUN npm ci
COPY frontend/ ./
@@ -15,8 +15,12 @@ RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
ARG COMMIT_HASH=unknown
WORKDIR /app
ENV COMMIT_HASH=${COMMIT_HASH}
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
@@ -29,6 +33,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

1718
LICENSES.md Normal file

File diff suppressed because it is too large Load Diff

245
README.md
View File

@@ -8,12 +8,13 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
* Access your radio remotely over your network or VPN
* Search for hashtag room names for channels you don't have keys for yet
* Forward decrypted packets to MQTT brokers
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
* Use the more recent 1.14 firmwares which support multibyte pathing
* Visualize the mesh as a map or node set, view repeater stats, and more!
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
![Screenshot of the application's web interface](screenshot.png)
![Screenshot of the application's web interface](app_screenshot.png)
## Disclaimer
@@ -21,13 +22,27 @@ This is developed with very heavy agentic assistance -- there is no warranty of
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
## Start Here
Most users should choose one of these paths:
1. Clone and build from source.
2. Download the prebuilt release zip if you are on a resource-constrained system and do not want to build the frontend locally.
3. Use Docker if that better matches how you deploy.
For advanced setup, troubleshooting, HTTPS, systemd service setup, and remediation environment variables, see [README_ADVANCED.md](README_ADVANCED.md).
If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
## Requirements
- Python 3.10+
- Node.js 18+
- Node.js LTS or current (20, 22, 24, 25) if you're not using a prebuilt release
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- MeshCore radio connected via USB serial, TCP, or BLE
If you are on a low-resource system and do not want to build the frontend locally, download the release zip named `remoteterm-prebuilt-frontend-vX.X.X-<short hash>.zip`. That bundle includes `frontend/prebuilt`, so you can run the app without doing a frontend build from source.
<details>
<summary>Finding your serial port</summary>
@@ -42,6 +57,12 @@ ls /dev/ttyUSB* /dev/ttyACM*
#######
ls /dev/cu.usbserial-* /dev/cu.usbmodem*
###########
# Windows
###########
# In PowerShell:
Get-CimInstance Win32_SerialPort | Select-Object DeviceID, Caption
######
# WSL2
######
@@ -50,12 +71,15 @@ winget install usbipd
# restart console
# then find device ID
usbipd list
# attach device to WSL
# make device shareable
usbipd bind --busid 3-8 # (or whatever the right ID is)
# attach device to WSL (run this each time you plug in the device)
usbipd attach --wsl --busid 3-8
# device will appear in WSL as /dev/ttyUSB0 or /dev/ttyACM0
```
</details>
## Quick Start
## Path 1: Clone And Build
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
@@ -63,37 +87,33 @@ usbipd bind --busid 3-8 # (or whatever the right ID is)
git clone https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
cd Remote-Terminal-for-MeshCore
# Install backend dependencies
uv sync
# Build frontend
cd frontend && npm install && npm run build && cd ..
# Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
The server auto-detects the serial port. To specify a transport manually:
Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`.
## Path 1.5: Use The Prebuilt Release Zip
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
If you downloaded the release zip instead of cloning the repo, unpack it and run:
```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
cd Remote-Terminal-for-MeshCore
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access at http://localhost:8000
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
## Path 2: 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.
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
@@ -126,7 +146,7 @@ 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.
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
To stop:
@@ -134,59 +154,9 @@ To stop:
docker compose down
```
## Development
## Standard Environment Variables
### Backend
```bash
uv sync
uv run uvicorn app.main:app --reload # autodetects serial port
# Or with explicit serial port
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
npm run build # Production build to dist/
```
Run both the backend and `npm run dev` for hot-reloading frontend development.
### Code Quality & Tests
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend.
Run everything at once (parallelized):
```bash
./scripts/all_quality.sh
```
<details>
<summary>Or run individual checks</summary>
```bash
# python
uv run ruff check app/ tests/ --fix # lint + auto-fix
uv run ruff format app/ tests/ # format (always writes)
uv run pyright app/ # type checking
PYTHONPATH=. uv run pytest tests/ -v # backend tests
# frontend
cd frontend
npm run lint:fix # esLint + auto-fix
npm run test:run # run tests
npm run format # prettier (always writes)
npm run build # build the frontend
```
</details>
## Configuration
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
| Variable | Default | Description |
|----------|---------|-------------|
@@ -196,107 +166,36 @@ npm run build # build the frontend
| `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_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config; an intermediate security precaution, but not as good as basic auth) |
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
## Additional Setup
<details>
<summary>HTTPS (Required for WebGPU room-finding outside localhost)</summary>
WebGPU requires a secure context. When not on `localhost`, serve over HTTPS:
Common launch patterns:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
# Serial (explicit port)
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
For Docker Compose, generate the cert and add the volume mounts and command override to `docker-compose.yaml`:
On Windows (PowerShell), set environment variables as a separate statement:
```bash
# generate snakeoil TLS cert
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
```powershell
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Then add the key and cert to the `remoteterm` service in `docker-compose.yaml`, and add an explicit launch command that uses them:
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
```yaml
volumes:
- ./data:/app/data
- ./cert.pem:/app/cert.pem:ro
- ./key.pem:/app/key.pem:ro
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
```
## Where To Go Next
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
</details>
<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
# Install to /opt/remoteterm
sudo mkdir -p /opt/remoteterm
sudo cp -r . /opt/remoteterm/
sudo chown -R remoteterm:remoteterm /opt/remoteterm
# Install dependencies
cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# Build frontend (required for the backend to serve the web UI)
cd /opt/remoteterm/frontend
sudo -u remoteterm npm install
sudo -u remoteterm npm run build
# Install and start service
sudo cp /opt/remoteterm/remoteterm.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now remoteterm
# Check status
sudo systemctl status remoteterm
sudo journalctl -u remoteterm -f
```
Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if needed.
</details>
<details>
<summary>Testing</summary>
**Backend:**
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
**Frontend:**
```bash
cd frontend
npm run test:run
```
**E2E:**
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly. E2E tests are generally not necessary to run for normal development work.
```bash
cd tests/e2e
npx playwright test # headless
npx playwright test --headed # show the browser window
```
</details>
## API Documentation
With the backend running: http://localhost:8000/docs
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
- Live API docs after the backend is running: http://localhost:8000/docs

118
README_ADVANCED.md Normal file
View File

@@ -0,0 +1,118 @@
# Advanced Setup And Troubleshooting
## Remediation Environment Variables
These are intended for diagnosing or working around radios that behave oddly.
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
- whether messages were left on the radio without reaching the app through event subscription
- whether the app's channel-slot expectations still match the radio's actual channel listing
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
## HTTPS
WebGPU room-finding requires a secure context when you are not on `localhost`.
Generate a local cert and start the backend with TLS:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
```
For Docker Compose, generate the cert, mount it into the container, and override the launch command:
```yaml
services:
remoteterm:
volumes:
- ./data:/app/data
- ./cert.pem:/app/cert.pem:ro
- ./key.pem:/app/key.pem:ro
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
```
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
## Systemd Service
Assumes you are running from `/opt/remoteterm`; adjust paths if you deploy elsewhere.
```bash
# Create service user
sudo useradd -r -m -s /bin/false remoteterm
# Install to /opt/remoteterm
sudo mkdir -p /opt/remoteterm
sudo cp -r . /opt/remoteterm/
sudo chown -R remoteterm:remoteterm /opt/remoteterm
# Install dependencies
cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# If deploying from a source checkout, build the frontend first
sudo -u remoteterm bash -lc 'cd /opt/remoteterm/frontend && npm install && npm run build'
# If deploying from the release zip artifact, frontend/prebuilt is already present
```
Create `/etc/systemd/system/remoteterm.service` with:
```ini
[Unit]
Description=RemoteTerm for MeshCore
After=network.target
[Service]
Type=simple
User=remoteterm
Group=remoteterm
WorkingDirectory=/opt/remoteterm
ExecStart=/opt/remoteterm/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5
Environment=MESHCORE_DATABASE_PATH=/opt/remoteterm/data/meshcore.db
# Uncomment and set if auto-detection doesn't work:
# Environment=MESHCORE_SERIAL_PORT=/dev/ttyUSB0
SupplementaryGroups=dialout
[Install]
WantedBy=multi-user.target
```
Then install and start it:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now remoteterm
sudo systemctl status remoteterm
sudo journalctl -u remoteterm -f
```
## Debug Logging And Bug Reports
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
```bash
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
You can also navigate to `/api/debug` (or go to Settings -> About -> "Open debug support snapshot" at the bottom). This debug block contains information about the operating environment, expectations around keys and channels, and radio status. It also includes the most recent logs. **Non-log information reveals no keys, channel names, or other privilege information beyond the names of your bots. The logs, however, may contain channel names or keys (but never your private key).** If you do not wish to include this information, copy up to the `STOP COPYING HERE` marker in the debug body.
## Development Notes
For day-to-day development, see [CONTRIBUTING.md](CONTRIBUTING.md).
Windows note: I've seen an intermittent startup issue like `"Received empty packet: index out of range"` with failed contact sync. I can't figure out why this happens. The issue typically resolves on restart. If you can figure out why this happens, I will buy you a virtual or iRL six pack if you're in the PNW. As a former always-windows-girlie before embracing WSL2, I despise second-classing M$FT users, but I'm just stuck with this one.

View File

@@ -11,6 +11,14 @@ Keep it aligned with `app/` source files and router behavior.
- MeshCore Python library (`meshcore` from PyPI)
- PyCryptodome
## Code Ethos
- Prefer strong domain modules over layers of pass-through helpers.
- Split code when the new module owns real policy, not just a nicer name.
- Avoid wrapper services around globals unless they materially improve testability or reduce coupling.
- Keep workflows locally understandable; do not scatter one reasoning unit across several files without a clear contract.
- Typed write/read contracts are preferred over loose dict-shaped repository inputs.
## Backend Map
```text
@@ -19,21 +27,34 @@ app/
├── config.py # Env-driven runtime settings
├── database.py # SQLite connection + base schema + migration runner
├── migrations.py # Schema migrations (SQLite user_version)
├── models.py # Pydantic request/response models
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings)
├── radio.py # RadioManager + auto-reconnect monitor
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
├── services/ # Shared orchestration/domain services
│ ├── messages.py # Shared message creation, dedup, ACK application
│ ├── message_send.py # Direct send, channel send, resend workflows
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
│ ├── dm_ack_tracker.py # Pending DM ACK state
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
│ ├── radio_commands.py # Radio config/private-key command workflows
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
├── radio.py # RadioManager transport/session state + lock management
├── radio_sync.py # Polling, sync, periodic advertisement loop
├── decoder.py # Packet parsing/decryption
├── packet_processor.py # Raw packet pipeline, dedup, path handling
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
├── events.py # Typed WS event payload serialization
├── websocket.py # WS manager + broadcast helpers
├── mqtt.py # Optional MQTT publisher (fire-and-forget forwarding)
├── bot.py # Bot execution and outbound bot sends
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── path_utils.py # Path hex rendering and hop-width helpers
├── region_scope.py # Normalize/validate regional flood-scope values
├── keystore.py # Ephemeral private/public key storage for DM decryption
├── frontend_static.py # Mount/serve built frontend (production)
└── routers/
├── health.py
├── debug.py
├── radio.py
├── contacts.py
├── channels.py
@@ -41,6 +62,7 @@ app/
├── packets.py
├── read_state.py
├── settings.py
├── fanout.py
├── repeaters.py
├── statistics.py
└── ws.py
@@ -52,35 +74,63 @@ app/
1. Radio emits events.
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
3. Decrypted messages are inserted into `messages` and broadcast over WS.
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
3. Shared message-domain services create/update `messages` and shape WS payloads.
4. Direct-message storage is centralized in `services/dm_ingest.py`; packet-processor DMs and `CONTACT_MSG_RECV` fallback events both route through that seam.
### Outgoing messages
1. Send endpoints in `routers/messages.py` call MeshCore commands.
2. Message is persisted as outgoing.
1. Send endpoints in `routers/messages.py` validate requests and delegate to `services/message_send.py`.
2. Service-layer send workflows call MeshCore commands, persist outgoing messages, and wire ACK tracking.
3. Endpoint broadcasts WS `message` event so all live clients update.
4. ACK/repeat updates arrive later as `message_acked` events.
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend — a safe assumption since name changes require a radio config update and are not something that happens mid-conversation.
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend. Name changes require an explicit radio config update and are rare, but the `new_timestamp=true` resend path has no time window, so a mismatch is possible if the name was changed between the original send and a later resend.
### Connection lifecycle
- `RadioManager.start_connection_monitor()` checks health every 5s.
- Monitor reconnect path runs `post_connect_setup()` before broadcasting healthy state.
- Manual reconnect/reboot endpoints call `reconnect()` then `post_connect_setup()`.
- Setup includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`.
- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly.
- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state.
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling. That audit checks both missed-radio-message drift and channel-slot cache drift; cache mismatches are logged, toasted, and the send-slot cache is reset.
- Post-connect setup is timeout-bounded. If initial radio offload/setup hangs too long, the backend logs the failure and broadcasts an `error` toast telling the operator to reboot the radio and restart the server.
## Important Behaviors
### Multibyte routing
- Packet `path_len` values are hop counts, not byte counts.
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
### Read/unread state
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
### DM ingest + ACKs
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
### Echo/repeat dedup
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented **only for outgoing messages**. Incoming repeats add path data but do not change the ACK count.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming direct messages with the same conversation/text/sender timestamp also collapse onto one stored row, with later observations merging path data instead of creating a second DM.
### Raw packet dedup policy
@@ -101,66 +151,64 @@ app/
- `0` means disabled.
- Last send time tracked in `app_settings.last_advert_time`.
### MQTT publishing
### Fanout bus
- Optional forwarding of mesh events to an external MQTT broker.
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
- Disabled when `mqtt_broker_host` is empty.
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`).
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
- All external integrations (MQTT, bots, webhooks, Apprise, SQS) are managed through the fanout bus (`app/fanout/`).
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
- Each integration is a `FanoutModule` with scope-based filtering.
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
## API Surface (all under `/api`)
### Health
- `GET /health`
### Debug
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
### Radio
- `GET /radio/config`
- `PATCH /radio/config`
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
- `PUT /radio/private-key`
- `POST /radio/advertise`
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
- `POST /radio/disconnect`
- `POST /radio/reboot`
- `POST /radio/reconnect`
### Contacts
- `GET /contacts`
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
- `GET /contacts/{public_key}`
- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters)
- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact
- `POST /contacts`
- `DELETE /contacts/{public_key}`
- `POST /contacts/sync`
- `POST /contacts/{public_key}/add-to-radio`
- `POST /contacts/{public_key}/remove-from-radio`
- `POST /contacts/{public_key}/mark-read`
- `POST /contacts/{public_key}/command`
- `POST /contacts/{public_key}/reset-path`
- `POST /contacts/{public_key}/routing-override`
- `POST /contacts/{public_key}/trace`
- `POST /contacts/{public_key}/repeater/login`
- `POST /contacts/{public_key}/repeater/status`
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
- `POST /contacts/{public_key}/repeater/neighbors`
- `POST /contacts/{public_key}/repeater/acl`
- `POST /contacts/{public_key}/repeater/node-info`
- `POST /contacts/{public_key}/repeater/radio-settings`
- `POST /contacts/{public_key}/repeater/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info`
### Channels
- `GET /channels`
- `GET /channels/{key}`
- `GET /channels/{key}/detail`
- `POST /channels`
- `DELETE /channels/{key}`
- `POST /channels/sync`
- `POST /channels/{key}/flood-scope-override`
- `POST /channels/{key}/mark-read`
### Messages
- `GET /messages`
- `GET /messages` — list with filters; supports `q` (full-text search), `after`/`after_id` (forward cursor)
- `GET /messages/around/{message_id}` — context messages around a target (for jump-to-message navigation)
- `POST /messages/direct`
- `POST /messages/channel`
- `POST /messages/channel/{message_id}/resend`
@@ -178,8 +226,16 @@ app/
- `GET /settings`
- `PATCH /settings`
- `POST /settings/favorites/toggle`
- `POST /settings/blocked-keys/toggle`
- `POST /settings/blocked-names/toggle`
- `POST /settings/migrate`
### Fanout
- `GET /fanout` — list all fanout configs
- `POST /fanout` — create new fanout config
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
- `DELETE /fanout/{id}` — delete fanout config (stops module)
### Statistics
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
@@ -190,26 +246,42 @@ app/
- `health` — radio connection status (broadcast on change, personal on connect)
- `contact` — single contact upsert (from advertisements and radio sync)
- `contact_resolved` — prefix contact reconciled to a full contact row (payload: `{ previous_public_key, contact }`)
- `message` — new message (channel or DM, from packet processor or send endpoints)
- `message_acked` — ACK/echo update for existing message (ack count + paths)
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
- `error` — toast notification (reconnect failure, missing private key, etc.)
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
- `channel` — single channel upsert/update (payload: full `Channel`)
- `channel_deleted` — channel removed from database (payload: `{ key }`)
- `error` — toast notification (reconnect failure, missing private key, stuck radio startup, etc.)
- `success` — toast notification (historical decrypt complete, etc.)
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
Backend WS sends go through typed serialization in `events.py`. Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
Client sends `"ping"` text; server replies `{"type":"pong"}`.
## Data Model Notes
Main tables:
- `contacts` (includes `first_seen` for contact age tracking)
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
- `channels`
Includes optional `flood_scope_override` for channel-specific regional sends.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
- `raw_packets`
- `contact_advert_paths` (recent unique advertisement paths per contact)
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
- `contact_name_history` (tracks name changes over time)
- `app_settings`
Contact route state is canonicalized on the backend:
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
`app_settings` fields in active model:
- `max_radio_contacts`
- `favorites`
@@ -219,13 +291,16 @@ Main tables:
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `bots`
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `flood_scope`
- `blocked_keys`, `blocked_names`
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
## Security Posture (intentional)
- No authn/authz.
- No per-user authn/authz model; optionally, operators may enable app-wide HTTP Basic auth for both HTTP and WS entrypoints.
- No CORS restriction (`*`).
- Bot code executes user-provided Python via `exec()`.
@@ -248,31 +323,48 @@ tests/
├── test_api.py # REST endpoint integration tests
├── test_bot.py # Bot execution and sandboxing
├── test_channels_router.py # Channels router endpoints
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
├── test_config.py # Configuration validation
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
├── test_contacts_router.py # Contacts router endpoints
├── test_decoder.py # Packet parsing/decryption
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
├── test_fanout_integration.py # Fanout integration tests
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
├── test_key_normalization.py # Public key normalization
├── test_keystore.py # Ephemeral keystore
├── test_message_pagination.py # Cursor-based message pagination
├── test_message_prefix_claim.py # Message prefix claim logic
├── test_migrations.py # Schema migration system
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
├── test_packet_pipeline.py # End-to-end packet processing
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
├── test_radio.py # RadioManager, serial detection
├── test_radio_commands_service.py # Radio config/private-key service workflows
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
├── test_real_crypto.py # Real cryptographic operations
├── test_radio_operation.py # radio_operation() context manager
├── test_radio_router.py # Radio router endpoints
├── test_radio_sync.py # Polling, sync, advertisement
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
├── test_repository.py # Data access layer
├── test_rx_log_data.py # on_rx_log_data event handler integration
├── test_messages_search.py # Message search, around, forward pagination
├── test_block_lists.py # Blocked keys/names filtering
├── test_security.py # Optional Basic Auth middleware / config behavior
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation
├── test_statistics.py # Statistics aggregation
├── test_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
```
@@ -283,7 +375,7 @@ tests/
The MeshCore radio protocol encodes `sender_timestamp` as a 4-byte little-endian integer (Unix seconds). This is a firmware-level wire format — the radio, the Python library (`commands/messaging.py`), and the decoder (`decoder.py`) all read/write exactly 4 bytes. Millisecond Unix timestamps would overflow 4 bytes, so higher resolution is not possible without a firmware change.
**Consequence:** The dedup index `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` operates at 1-second granularity. Sending identical text to the same conversation twice within one second will hit the UNIQUE constraint on the second insert, returning HTTP 500 *after* the radio has already transmitted. The message is sent over the air but not stored in the database. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`.
**Consequence:** Message dedup still operates at 1-second granularity because the radio protocol only provides second-resolution `sender_timestamp`. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`. Incoming DMs now share the same second-resolution content identity tradeoff as channel echoes: same-contact same-text same-second observations collapse onto one stored row.
### Outgoing DM echoes remain undecrypted

10
app/channel_constants.py Normal file
View File

@@ -0,0 +1,10 @@
PUBLIC_CHANNEL_KEY = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
PUBLIC_CHANNEL_NAME = "Public"
def is_public_channel_key(key: str) -> bool:
return key.upper() == PUBLIC_CHANNEL_KEY
def is_public_channel_name(name: str) -> bool:
return name.casefold() == PUBLIC_CHANNEL_NAME.casefold()

View File

@@ -1,7 +1,10 @@
import logging
import logging.config
from collections import deque
from threading import Lock
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,6 +19,15 @@ class Settings(BaseSettings):
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
disable_bots: bool = False
enable_message_poll_fallback: bool = False
force_channel_slot_reconfigure: bool = False
clowntown_do_clock_wraparound: bool = Field(
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
basic_auth_username: str = ""
basic_auth_password: str = ""
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
@@ -33,13 +45,13 @@ class Settings(BaseSettings):
)
if self.ble_address and not self.ble_pin:
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
if self.basic_auth_partially_configured:
raise ValueError(
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
"must be set together."
)
return self
@property
def loopback_eligible(self) -> bool:
"""True when no explicit transport env var is set."""
return not self.serial_port and not self.tcp_host and not self.ble_address
@property
def connection_type(self) -> Literal["serial", "tcp", "ble"]:
if self.tcp_host:
@@ -48,14 +60,158 @@ class Settings(BaseSettings):
return "ble"
return "serial"
@property
def basic_auth_enabled(self) -> bool:
return bool(self.basic_auth_username and self.basic_auth_password)
@property
def basic_auth_partially_configured(self) -> bool:
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
return any_credentials_set and not self.basic_auth_enabled
settings = Settings()
class _RingBufferLogHandler(logging.Handler):
"""Keep a bounded in-memory tail of formatted log lines."""
def __init__(self, max_lines: int = 1000) -> None:
super().__init__()
self._buffer: deque[str] = deque(maxlen=max_lines)
self._lock = Lock()
def emit(self, record: logging.LogRecord) -> None:
try:
line = self.format(record)
except Exception:
self.handleError(record)
return
with self._lock:
self._buffer.append(line)
def get_lines(self, limit: int = 1000) -> list[str]:
with self._lock:
if limit <= 0:
return []
return list(self._buffer)[-limit:]
def clear(self) -> None:
with self._lock:
self._buffer.clear()
_recent_log_handler = _RingBufferLogHandler(max_lines=1000)
def get_recent_log_lines(limit: int = 1000) -> list[str]:
"""Return recent formatted log lines from the in-memory ring buffer."""
return _recent_log_handler.get_lines(limit)
def clear_recent_log_lines() -> None:
"""Clear the in-memory log ring buffer."""
_recent_log_handler.clear()
class _RepeatSquelch(logging.Filter):
"""Suppress rapid-fire identical messages and emit a summary instead.
Attached to the ``meshcore`` library logger to catch its repeated
"Serial Connection started" lines that flood the log when another
process holds the serial port.
"""
def __init__(self, threshold: int = 3) -> None:
super().__init__()
self._last_msg: str | None = None
self._repeat_count: int = 0
self._threshold = threshold
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
if msg == self._last_msg:
self._repeat_count += 1
if self._repeat_count == self._threshold:
record.msg = (
"%s (repeated %d times — possible serial port contention from another process)"
)
record.args = (msg, self._repeat_count)
record.levelno = logging.WARNING
record.levelname = "WARNING"
return True
# Suppress further repeats beyond the threshold
return self._repeat_count < self._threshold
else:
self._last_msg = msg
self._repeat_count = 1
return True
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
level=settings.log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"uvicorn_access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(asctime)s - %(name)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s',
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": None,
},
},
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "default",
},
"uvicorn_access": {
"class": "logging.StreamHandler",
"formatter": "uvicorn_access",
},
},
"root": {
"level": settings.log_level,
"handlers": ["default"],
},
"loggers": {
"uvicorn": {
"level": settings.log_level,
"handlers": ["default"],
"propagate": False,
},
"uvicorn.error": {
"level": settings.log_level,
"handlers": ["default"],
"propagate": False,
},
"uvicorn.access": {
"level": settings.log_level,
"handlers": ["uvicorn_access"],
"propagate": False,
},
},
}
)
_recent_log_handler.setLevel(logging.DEBUG)
_recent_log_handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
for logger_name in ("", "uvicorn", "uvicorn.error", "uvicorn.access"):
target = logging.getLogger(logger_name)
if _recent_log_handler not in target.handlers:
target.addHandler(_recent_log_handler)
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
# "Serial Connection started" when the port is contended).
logging.getLogger("meshcore").addFilter(_RepeatSquelch())

View File

@@ -13,22 +13,30 @@ CREATE TABLE IF NOT EXISTS contacts (
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER
first_seen INTEGER,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS channels (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS messages (
@@ -45,10 +53,9 @@ CREATE TABLE IF NOT EXISTS messages (
acked INTEGER DEFAULT 0,
sender_name TEXT,
sender_key TEXT
-- Deduplication: identical text + timestamp in the same conversation is treated as a
-- mesh echo/repeat. Second-precision timestamps mean two intentional identical messages
-- within the same second would collide, but this is not feasible in practice — LoRa
-- transmission takes several seconds per message, and the UI clears the input on send.
-- Deduplication: channel echoes/repeats use a content/time unique index so
-- duplicate observations reconcile onto a single stored row. Legacy
-- databases may also gain an incoming-DM content index via migration 44.
-- Enforced via idx_messages_dedup_null_safe (unique index) rather than a table constraint
-- to avoid the storage overhead of SQLite's autoindex duplicating every message text.
);
@@ -70,7 +77,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex),
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
@@ -86,11 +93,13 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0));
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN';
CREATE 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)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
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

View File

@@ -58,6 +58,28 @@ class DecryptedDirectMessage:
message: str
dest_hash: str # First byte of destination pubkey as hex
src_hash: str # First byte of sender pubkey as hex
signed_sender_prefix: str | None = None
@property
def txt_type(self) -> int:
return self.flags >> 2
@property
def attempt(self) -> int:
return self.flags & 0x03
@dataclass
class DecryptedPathPayload:
"""Result of decrypting a PATH payload."""
dest_hash: str
src_hash: str
returned_path: bytes
returned_path_len: int
returned_path_hash_mode: int
extra_type: int
extra: bytes
@dataclass
@@ -79,18 +101,14 @@ class PacketInfo:
route_type: RouteType
payload_type: PayloadType
payload_version: int
path_length: int
path: bytes # The routing path (empty if path_length is 0)
path_length: int # Decoded hop count (not the raw wire byte)
path: bytes # The routing path bytes (empty if path_length is 0)
payload: bytes
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
def calculate_channel_hash(channel_key: bytes) -> str:
"""
Calculate the channel hash from a 16-byte channel key.
Returns the first byte of SHA256(key) as hex.
"""
hash_bytes = hashlib.sha256(channel_key).digest()
return format(hash_bytes[0], "02x")
def _is_valid_advert_location(lat: float, lon: float) -> bool:
return -90 <= lat <= 90 and -180 <= lon <= 180
def extract_payload(raw_packet: bytes) -> bytes | None:
@@ -100,86 +118,36 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
Packet structure:
- Byte 0: header (route_type, payload_type, version)
- For TRANSPORT routes: bytes 1-4 are transport codes
- Next byte: path_length
- Next path_length bytes: path data
- Next byte: path byte (packed as [hash_mode:2][hop_count:6])
- Next hop_count * hash_size bytes: path data
- Remaining: payload
Returns the payload bytes, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
from app.path_utils import parse_packet_envelope
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Skip path data
if len(raw_packet) < offset + path_length:
return None
offset += path_length
# Rest is payload
return raw_packet[offset:]
except (ValueError, IndexError):
return None
envelope = parse_packet_envelope(raw_packet)
return envelope.payload if envelope is not None else None
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
"""Parse a raw packet and extract basic info."""
if len(raw_packet) < 2:
from app.path_utils import parse_packet_envelope
envelope = parse_packet_envelope(raw_packet)
if envelope is None:
return None
try:
header = raw_packet[0]
route_type = RouteType(header & 0x03)
payload_type = PayloadType((header >> 2) & 0x0F)
payload_version = (header >> 6) & 0x03
offset = 1
# Skip transport codes if present
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Extract path data
if len(raw_packet) < offset + path_length:
return None
path = raw_packet[offset : offset + path_length]
offset += path_length
# Rest is payload
payload = raw_packet[offset:]
return PacketInfo(
route_type=route_type,
payload_type=payload_type,
payload_version=payload_version,
path_length=path_length,
path=path,
payload=payload,
route_type=RouteType(envelope.route_type),
payload_type=PayloadType(envelope.payload_type),
payload_version=envelope.payload_version,
path_length=envelope.hop_count,
path_hash_size=envelope.hash_size,
path=envelope.path,
payload=envelope.payload,
)
except (ValueError, IndexError):
except ValueError:
return None
@@ -282,7 +250,7 @@ def try_decrypt_packet_with_channel_key(
return None
packet_channel_hash = format(packet_info.payload[0], "02x")
expected_hash = calculate_channel_hash(channel_key)
expected_hash = format(hashlib.sha256(channel_key).digest()[0], "02x")
if packet_channel_hash != expected_hash:
return None
@@ -301,7 +269,9 @@ def get_packet_payload_type(raw_packet: bytes) -> PayloadType | None:
return None
def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
def parse_advertisement(
payload: bytes, raw_packet: bytes | None = None
) -> ParsedAdvertisement | None:
"""
Parse an advertisement payload.
@@ -357,6 +327,16 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
lon_raw = int.from_bytes(payload[offset + 4 : offset + 8], byteorder="little", signed=True)
lat = lat_raw / 1_000_000
lon = lon_raw / 1_000_000
if not _is_valid_advert_location(lat, lon):
packet_hex = (raw_packet if raw_packet is not None else payload).hex().upper()
logger.warning(
"Dropping location data for nonsensical packet -- packet %s implies lat/lon %s/%s. Outta this world!",
packet_hex,
lat,
lon,
)
lat = None
lon = None
offset += 8
# Skip feature fields if present
@@ -527,10 +507,19 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
# Extract message text (UTF-8, null-padded)
message_bytes = decrypted[5:]
signed_sender_prefix: str | None = None
txt_type = flags >> 2
if txt_type == 2:
if len(message_bytes) < 4:
return None
signed_sender_prefix = message_bytes[:4].hex()
message_bytes = message_bytes[4:]
try:
message_text = message_bytes.decode("utf-8")
# Remove null terminator and any padding
message_text = message_text.rstrip("\x00")
# Truncate at first null terminator (consistent with channel message handling)
null_idx = message_text.find("\x00")
if null_idx >= 0:
message_text = message_text[:null_idx]
except UnicodeDecodeError:
return None
@@ -540,6 +529,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
message=message_text,
dest_hash=dest_hash,
src_hash=src_hash,
signed_sender_prefix=signed_sender_prefix,
)
@@ -603,3 +593,88 @@ def try_decrypt_dm(
return None
return decrypt_direct_message(packet_info.payload, shared_secret)
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
"""Decrypt a PATH payload using the ECDH shared secret."""
if len(payload) < 4:
return None
dest_hash = format(payload[0], "02x")
src_hash = format(payload[1], "02x")
mac = payload[2:4]
ciphertext = payload[4:]
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
return None
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
if calculated_mac != mac:
return None
try:
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext)
except Exception as e:
logger.debug("AES decryption failed for PATH payload: %s", e)
return None
if len(decrypted) < 2:
return None
from app.path_utils import decode_path_byte
packed_len = decrypted[0]
try:
returned_path_len, hash_size = decode_path_byte(packed_len)
except ValueError:
return None
path_byte_len = returned_path_len * hash_size
if len(decrypted) < 1 + path_byte_len + 1:
return None
offset = 1
returned_path = decrypted[offset : offset + path_byte_len]
offset += path_byte_len
extra_type = decrypted[offset] & 0x0F
offset += 1
extra = decrypted[offset:]
return DecryptedPathPayload(
dest_hash=dest_hash,
src_hash=src_hash,
returned_path=returned_path,
returned_path_len=returned_path_len,
returned_path_hash_mode=hash_size - 1,
extra_type=extra_type,
extra=extra,
)
def try_decrypt_path(
raw_packet: bytes,
our_private_key: bytes,
their_public_key: bytes,
our_public_key: bytes,
) -> DecryptedPathPayload | None:
"""Try to decrypt a raw packet as a PATH packet."""
packet_info = parse_packet(raw_packet)
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
return None
if len(packet_info.payload) < 4:
return None
dest_hash = packet_info.payload[0]
src_hash = packet_info.payload[1]
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
return None
try:
shared_secret = derive_shared_secret(our_private_key, their_public_key)
except Exception as e:
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
return None
return decrypt_path_payload(packet_info.payload, shared_secret)

View File

@@ -1,17 +1,8 @@
"""Shared dependencies for FastAPI routers."""
from fastapi import HTTPException
from app.radio import radio_manager
from app.services.radio_runtime import radio_runtime as radio_manager
def require_connected():
"""Dependency that ensures radio is connected and returns meshcore instance.
Raises HTTPException 503 if radio is not connected.
"""
if getattr(radio_manager, "is_setup_in_progress", False) is True:
raise HTTPException(status_code=503, detail="Radio is initializing")
if not radio_manager.is_connected or radio_manager.meshcore is None:
raise HTTPException(status_code=503, detail="Radio not connected")
return radio_manager.meshcore
"""Dependency that ensures radio is connected and returns meshcore instance."""
return radio_manager.require_connected()

View File

@@ -1,17 +1,24 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import CONTACT_TYPE_REPEATER, Contact, Message, MessagePath
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
from app.packet_processor import process_raw_packet
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactNameHistoryRepository,
ContactRepository,
MessageRepository,
)
from app.services import dm_ack_tracker
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.dm_ingest import (
ingest_fallback_direct_message,
resolve_direct_message_sender_metadata,
resolve_fallback_direct_message_context,
)
from app.websocket import broadcast_event
@@ -23,33 +30,18 @@ logger = logging.getLogger(__name__)
# Track active subscriptions so we can unsubscribe before re-registering
# This prevents handler duplication after reconnects
_active_subscriptions: list["Subscription"] = []
_pending_acks = dm_ack_tracker._pending_acks
_buffered_acks = dm_ack_tracker._buffered_acks
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
_pending_acks: dict[str, tuple[int, float, int]] = {}
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
"""Track a pending ACK for a direct message."""
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
logger.debug(
"Tracking pending ACK %s for message %d (timeout %dms)",
expected_ack,
message_id,
timeout_ms,
)
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
"""Compatibility wrapper for pending DM ACK tracking."""
return dm_ack_tracker.track_pending_ack(expected_ack, message_id, timeout_ms)
def cleanup_expired_acks() -> None:
"""Remove expired pending ACKs."""
now = time.time()
expired = []
for code, (_msg_id, created_at, timeout_ms) in _pending_acks.items():
if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer
expired.append(code)
for code in expired:
del _pending_acks[code]
logger.debug("Expired pending ACK %s", code)
"""Compatibility wrapper for expiring stale DM ACK entries."""
dm_ack_tracker.cleanup_expired_acks()
async def on_contact_message(event: "Event") -> None:
@@ -62,8 +54,8 @@ async def on_contact_message(event: "Event") -> None:
2. The packet processor couldn't match the sender to a known contact
The packet processor handles: decryption, storage, broadcast, bot trigger.
This handler only stores if the packet processor didn't already handle it
(detected via INSERT OR IGNORE returning None for duplicates).
This handler adapts CONTACT_MSG_RECV payloads into the shared DM ingest
workflow, which reconciles duplicates against the packet pipeline when possible.
"""
payload = event.payload
@@ -77,92 +69,68 @@ async def on_contact_message(event: "Event") -> None:
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
received_at = int(time.time())
# Look up contact from database - use prefix lookup only if needed
# (get_by_key_or_prefix does exact match first, then prefix fallback)
try:
contact = await ContactRepository.get_by_key_or_prefix(sender_pubkey)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
sender_pubkey,
)
contact = None
if contact:
sender_pubkey = contact.public_key.lower()
# Promote any prefix-stored messages to this full key
await MessageRepository.claim_prefix_messages(sender_pubkey)
# Skip messages from repeaters - they only send CLI responses, not chat messages.
# CLI responses are handled by the command endpoint and txt_type filter above.
if contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (not stored in chat history)",
sender_pubkey[:12],
)
return
# Try to create message - INSERT OR IGNORE handles duplicates atomically
# If the packet processor already stored this message, this returns None
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=payload.get("text", ""),
conversation_key=sender_pubkey,
sender_timestamp=payload.get("sender_timestamp") or received_at,
context = await resolve_fallback_direct_message_context(
sender_public_key=sender_pubkey,
received_at=received_at,
path=payload.get("path"),
broadcast_fn=broadcast_event,
contact_repository=ContactRepository,
log=logger,
)
if context.skip_storage:
logger.debug(
"Skipping message from repeater %s (not stored in chat history)",
context.conversation_key[:12],
)
return
# Try to create or reconcile the message via the shared DM ingest service.
ts = payload.get("sender_timestamp")
sender_timestamp = ts if ts is not None else received_at
path = payload.get("path")
path_len = payload.get("path_len")
sender_name = context.sender_name
sender_key = context.sender_key
signature = payload.get("signature")
if (
context.contact is not None
and context.contact.type == CONTACT_TYPE_ROOM
and txt_type == 2
and isinstance(signature, str)
and signature
):
sender_name, sender_key = await resolve_direct_message_sender_metadata(
sender_public_key=signature,
received_at=received_at,
broadcast_fn=broadcast_event,
contact_repository=ContactRepository,
log=logger,
)
message = await ingest_fallback_direct_message(
conversation_key=context.conversation_key,
text=payload.get("text", ""),
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
signature=payload.get("signature"),
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
broadcast_fn=broadcast_event,
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
)
if msg_id is None:
if message is None:
# Already handled by packet processor (or exact duplicate) - nothing more to do
logger.debug("DM from %s already processed by packet processor", sender_pubkey[:12])
logger.debug(
"DM from %s already processed by packet processor", context.conversation_key[:12]
)
return
# If we get here, the packet processor didn't handle this message
# (likely because private key export is not available)
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
# Build paths array for broadcast
path = payload.get("path")
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None
# Broadcast the new message
broadcast_event(
"message",
Message(
id=msg_id,
type="PRIV",
conversation_key=sender_pubkey,
text=payload.get("text", ""),
sender_timestamp=payload.get("sender_timestamp") or received_at,
received_at=received_at,
paths=paths,
txt_type=txt_type,
signature=payload.get("signature"),
).model_dump(),
)
# Update contact last_contacted (contact was already fetched above)
if contact:
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
# Run bot if enabled
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=contact.name if contact else None,
sender_key=sender_pubkey,
message_text=payload.get("text", ""),
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=payload.get("sender_timestamp"),
path=payload.get("path"),
is_outgoing=False,
)
logger.debug(
"DM from %s handled by event handler (fallback path)", context.conversation_key[:12]
)
@@ -216,6 +184,7 @@ async def on_path_update(event: "Event") -> None:
# so if path fields are absent here we treat this as informational only.
path = payload.get("path")
path_len = payload.get("path_len")
path_hash_mode = payload.get("path_hash_mode")
if path is None or path_len is None:
logger.debug(
"PATH_UPDATE for %s has no path payload, skipping DB update", contact.public_key[:12]
@@ -230,7 +199,29 @@ async def on_path_update(event: "Event") -> None:
)
return
await ContactRepository.update_path(contact.public_key, str(path), normalized_path_len)
normalized_path_hash_mode: int | None
if path_hash_mode is None:
# Legacy firmware/library payloads only support 1-byte hop hashes.
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
else:
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_direct_path(
contact.public_key,
str(path),
normalized_path_len,
normalized_path_hash_mode,
updated_at=int(time.time()),
)
async def on_new_contact(event: "Event") -> None:
@@ -248,23 +239,42 @@ async def on_new_contact(event: "Event") -> None:
logger.debug("New contact: %s", public_key[:12])
contact_data = {
**Contact.from_radio_dict(public_key.lower(), payload, on_radio=True),
"last_seen": int(time.time()),
}
await ContactRepository.upsert(contact_data)
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False)
contact_upsert.last_seen = int(time.time())
await ContactRepository.upsert(contact_upsert)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
# Record name history if contact has a name
adv_name = payload.get("adv_name")
if adv_name:
await ContactNameHistoryRepository.record_name(
public_key.lower(), adv_name, int(time.time())
)
await record_contact_name_and_reconcile(
public_key=public_key,
contact_name=adv_name,
timestamp=int(time.time()),
log=logger,
)
# Read back from DB so the broadcast includes all fields (last_contacted,
# last_read_at, etc.) matching the REST Contact shape exactly.
db_contact = await ContactRepository.get_by_key(public_key)
broadcast_event("contact", (db_contact.model_dump() if db_contact else contact_data))
broadcast_event(
"contact",
(
db_contact.model_dump()
if db_contact
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
),
)
if db_contact:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": db_contact.model_dump(),
},
)
async def on_ack(event: "Event") -> None:
@@ -277,18 +287,9 @@ async def on_ack(event: "Event") -> None:
return
logger.debug("Received ACK with code %s", ack_code)
cleanup_expired_acks()
if ack_code in _pending_acks:
message_id, _, _ = _pending_acks.pop(ack_code)
logger.info("ACK received for message %d", message_id)
ack_count = await MessageRepository.increment_ack_count(message_id)
# DM ACKs don't carry path data, so paths is intentionally omitted.
# The frontend's mergePendingAck handles the missing field correctly,
# preserving any previously known paths.
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info("ACK received for code %s", ack_code)
else:
logger.debug("ACK code %s does not match any pending messages", ack_code)

115
app/events.py Normal file
View File

@@ -0,0 +1,115 @@
"""Typed WebSocket event contracts and serialization helpers."""
import json
import logging
from typing import Any, Literal
from pydantic import TypeAdapter
from typing_extensions import NotRequired, TypedDict
from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast
from app.routers.health import HealthResponse
logger = logging.getLogger(__name__)
WsEventType = Literal[
"health",
"message",
"contact",
"contact_resolved",
"channel",
"contact_deleted",
"channel_deleted",
"raw_packet",
"message_acked",
"error",
"success",
]
class ContactDeletedPayload(TypedDict):
public_key: str
class ContactResolvedPayload(TypedDict):
previous_public_key: str
contact: Contact
class ChannelDeletedPayload(TypedDict):
key: str
class MessageAckedPayload(TypedDict):
message_id: int
ack_count: int
paths: NotRequired[list[MessagePath]]
class ToastPayload(TypedDict):
message: str
details: NotRequired[str]
WsEventPayload = (
HealthResponse
| Message
| Contact
| ContactResolvedPayload
| Channel
| ContactDeletedPayload
| ChannelDeletedPayload
| RawPacketBroadcast
| MessageAckedPayload
| ToastPayload
)
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
"health": TypeAdapter(HealthResponse),
"message": TypeAdapter(Message),
"contact": TypeAdapter(Contact),
"contact_resolved": TypeAdapter(ContactResolvedPayload),
"channel": TypeAdapter(Channel),
"contact_deleted": TypeAdapter(ContactDeletedPayload),
"channel_deleted": TypeAdapter(ChannelDeletedPayload),
"raw_packet": TypeAdapter(RawPacketBroadcast),
"message_acked": TypeAdapter(MessageAckedPayload),
"error": TypeAdapter(ToastPayload),
"success": TypeAdapter(ToastPayload),
}
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
return adapter.validate_python(data)
def dump_ws_event(event_type: str, data: Any) -> str:
"""Serialize a WebSocket event envelope with validation for known event types."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return json.dumps({"type": event_type, "data": data})
try:
validated = adapter.validate_python(data)
payload = adapter.dump_python(validated, mode="json")
return json.dumps({"type": event_type, "data": payload})
except Exception:
logger.exception(
"Failed to validate WebSocket payload for event %s; falling back to raw JSON envelope",
event_type,
)
return json.dumps({"type": event_type, "data": data})
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
"""Return the JSON-serializable payload for a WebSocket event."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
validated = adapter.validate_python(data)
return adapter.dump_python(validated, mode="json")

297
app/fanout/AGENTS_fanout.md Normal file
View File

@@ -0,0 +1,297 @@
# Fanout Bus Architecture
The fanout bus is a unified system for dispatching mesh radio events (decoded messages and raw packets) to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
## Core Concepts
### FanoutModule (base.py)
Base class that all integration modules extend:
- `__init__(config_id, config, *, name="")` — constructor; receives the config UUID, the type-specific config dict, and the user-assigned name
- `start()` / `stop()` — async lifecycle (e.g. open/close connections)
- `on_message(data)` — receive decoded messages (DM/channel)
- `on_raw(data)` — receive raw RF packets
- `status` property (**must override**) — return `"connected"`, `"disconnected"`, or `"error"`
### FanoutManager (manager.py)
Singleton that owns all active modules and dispatches events:
- `load_from_db()` — startup: load enabled configs, instantiate modules
- `reload_config(id)` — CRUD: stop old, start new
- `remove_config(id)` — delete: stop and remove
- `broadcast_message(data)` — scope-check + dispatch `on_message`
- `broadcast_raw(data)` — scope-check + dispatch `on_raw`
- `stop_all()` — shutdown
- `get_statuses()` — health endpoint data
All modules are constructed uniformly: `cls(config_id, config_blob, name=cfg.get("name", ""))`.
### Scope Matching
Each config has a `scope` JSON blob controlling what events reach it:
```json
{"messages": "all", "raw_packets": "all"}
{"messages": "none", "raw_packets": "all"}
{"messages": {"channels": ["key1"], "contacts": "all"}, "raw_packets": "none"}
```
Community MQTT always enforces `{"messages": "none", "raw_packets": "all"}`.
## Event Flow
```
Radio Event -> packet_processor / event_handler
-> broadcast_event("message"|"raw_packet", data, realtime=True)
-> WebSocket broadcast (always)
-> FanoutManager.broadcast_message/raw (only if realtime=True)
-> scope check per module
-> module.on_message / on_raw
```
Setting `realtime=False` (used during historical decryption) skips fanout dispatch entirely.
## Current Module Types
### mqtt_private (mqtt_private.py)
Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `username`, `password`
- `use_tls`, `tls_insecure`, `topic_prefix`
### mqtt_community (mqtt_community.py)
Wraps `CommunityMqttPublisher` from `app/fanout/community_mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `iata`, `email`
- Only publishes raw packets (on_message is a no-op)
- The published `raw` field is always the original packet hex.
- When a direct packet includes a `path` field, it is emitted as comma-separated hop identifiers exactly as the packet reports them. Token width varies with the packet's path hash mode (`1`, `2`, or `3` bytes per hop); there is no legacy flat per-byte companion field.
### bot (bot.py)
Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- `code` — Python bot function source code
- Executes in a thread pool with timeout and semaphore concurrency control
- Rate-limits outgoing messages for repeater compatibility
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
### webhook (webhook.py)
HTTP webhook delivery. Config blob:
- `url`, `method` (POST/PUT/PATCH)
- `hmac_secret` (optional) — when set, each request includes an HMAC-SHA256 signature of the JSON body
- `hmac_header` (optional, default `X-Webhook-Signature`) — header name for the signature (value format: `sha256=<hex>`)
- `headers` — arbitrary extra headers (JSON object)
### apprise (apprise_mod.py)
Push notifications via Apprise library. Config blob:
- `urls` — newline-separated Apprise notification service URLs
- `preserve_identity` — suppress Discord webhook name/avatar override
- `include_path` — include routing path in notification body
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
### sqs (sqs.py)
Amazon SQS delivery. Config blob:
- `queue_url` — target queue URL
- `region_name` (optional; inferred from standard AWS SQS queue URLs when omitted), `endpoint_url` (optional)
- `access_key_id`, `secret_access_key`, `session_token` (all optional; blank uses the normal AWS credential chain)
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
- Supports both decoded messages and raw packets via normal scope selection
## Adding a New Integration Type
### Step-by-step checklist
#### 1. Backend module (`app/fanout/my_type.py`)
Create a class extending `FanoutModule`:
```python
from app.fanout.base import FanoutModule
class MyTypeModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
# Initialize module-specific state
async def start(self) -> None:
"""Open connections, create clients, etc."""
async def stop(self) -> None:
"""Close connections, clean up resources."""
async def on_message(self, data: dict) -> None:
"""Handle decoded messages. Omit if not needed."""
async def on_raw(self, data: dict) -> None:
"""Handle raw packets. Omit if not needed."""
@property
def status(self) -> str:
"""Required. Return 'connected', 'disconnected', or 'error'."""
...
```
Constructor requirements:
- Must accept `config_id: str, config: dict, *, name: str = ""`
- Must forward `name` to super: `super().__init__(config_id, config, name=name)`
#### 2. Register in manager (`app/fanout/manager.py`)
Add import and mapping in `_register_module_types()`:
```python
from app.fanout.my_type import MyTypeModule
_MODULE_TYPES["my_type"] = MyTypeModule
```
#### 3. Router changes (`app/routers/fanout.py`)
Three changes needed:
**a)** Add to `_VALID_TYPES` set:
```python
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "my_type"}
```
**b)** Add a validation function:
```python
def _validate_my_type_config(config: dict) -> None:
"""Validate my_type config blob."""
if not config.get("some_required_field"):
raise HTTPException(status_code=400, detail="some_required_field is required")
```
**c)** Wire validation into both `create_fanout_config` and `update_fanout_config` — add an `elif` to the validation block in each:
```python
elif body.type == "my_type":
_validate_my_type_config(body.config)
```
Note: validation only runs when the config will be enabled (disabled configs are treated as drafts).
**d)** Add scope enforcement in `_enforce_scope()` if the type has fixed scope constraints (e.g. raw_packets always none). Otherwise it falls through to the `mqtt_private` default which allows both messages and raw_packets to be configurable.
#### 4. Frontend editor component (`SettingsFanoutSection.tsx`)
Four changes needed in this single file:
**a)** Add to `TYPE_LABELS` and `TYPE_OPTIONS` at the top:
```tsx
const TYPE_LABELS: Record<string, string> = {
// ... existing entries ...
my_type: 'My Type',
};
const TYPE_OPTIONS = [
// ... existing entries ...
{ value: 'my_type', label: 'My Type' },
];
```
**b)** Create an editor component (follows the same pattern as existing editors):
```tsx
function MyTypeConfigEditor({
config,
scope,
onChange,
onScopeChange,
}: {
config: Record<string, unknown>;
scope: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
onScopeChange: (scope: Record<string, unknown>) => void;
}) {
return (
<div className="space-y-3">
{/* Type-specific config fields */}
<Separator />
<ScopeSelector scope={scope} onChange={onScopeChange} />
</div>
);
}
```
If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`.
The `ScopeSelector` component is defined within the same file. It accepts an optional `showRawPackets` prop:
- **Without `showRawPackets`** (webhook, apprise): shows message scope only (all/only/except — no "none" option since that would make the integration a no-op). A warning appears when the effective selection matches nothing.
- **With `showRawPackets`** (private MQTT): adds a "Forward raw packets" toggle and includes the "No messages" option (valid when raw packets are enabled). The warning appears only when both raw packets and messages are effectively disabled.
**c)** Add default config and scope in `handleAddCreate`:
```tsx
const defaults: Record<string, Record<string, unknown>> = {
// ... existing entries ...
my_type: { some_field: '', other_field: true },
};
const defaultScopes: Record<string, Record<string, unknown>> = {
// ... existing entries ...
my_type: { messages: 'all', raw_packets: 'none' },
};
```
**d)** Wire the editor into the detail view's conditional render block:
```tsx
{editingConfig.type === 'my_type' && (
<MyTypeConfigEditor
config={editConfig}
scope={editScope}
onChange={setEditConfig}
onScopeChange={setEditScope}
/>
)}
```
#### 5. Tests
**Backend integration tests** (`tests/test_fanout_integration.py`):
- Test that a configured + enabled module receives messages via `FanoutManager.broadcast_message`
- Test scope filtering (all, none, selective)
- Test that a disabled module does not receive messages
**Backend unit tests** (`tests/test_fanout_hitlist.py` or a dedicated file):
- Test config validation (required fields, bad values)
- Test module-specific logic in isolation
**Frontend tests** (`frontend/src/test/fanoutSection.test.tsx`):
- The existing suite covers the list/edit/create flow generically. If your editor has special behavior, add specific test cases.
#### Summary of files to touch
| File | Change |
|------|--------|
| `app/fanout/my_type.py` | New module class |
| `app/fanout/manager.py` | Import + register in `_register_module_types()` |
| `app/routers/fanout.py` | `_VALID_TYPES` + validator function + scope enforcement |
| `frontend/.../SettingsFanoutSection.tsx` | `TYPE_LABELS` + `TYPE_OPTIONS` + editor component + defaults + detail view wiring |
| `tests/test_fanout_integration.py` | Integration tests |
## REST API
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new config |
| PATCH | `/api/fanout/{id}` | Update config (triggers module reload) |
| DELETE | `/api/fanout/{id}` | Delete config (stops module) |
## Database
`fanout_configs` table:
- `id` TEXT PRIMARY KEY
- `type`, `name`, `enabled`, `config` (JSON), `scope` (JSON)
- `sort_order`, `created_at`
Migrations:
- **36**: Creates `fanout_configs` table, migrates existing MQTT settings from `app_settings`
- **37**: Migrates bot configs from `app_settings.bots` JSON column into fanout rows
- **38**: Drops legacy `mqtt_*`, `community_mqtt_*`, and `bots` columns from `app_settings`
## Key Files
- `app/fanout/base.py` — FanoutModule base class
- `app/fanout/manager.py` — FanoutManager singleton
- `app/fanout/mqtt_base.py` — BaseMqttPublisher ABC (shared MQTT connection loop)
- `app/fanout/mqtt.py` — MqttPublisher (private MQTT publishing)
- `app/fanout/community_mqtt.py` — CommunityMqttPublisher (community MQTT with JWT auth)
- `app/fanout/mqtt_private.py` — Private MQTT fanout module
- `app/fanout/mqtt_community.py` — Community MQTT fanout module
- `app/fanout/bot.py` — Bot fanout module
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
- `app/fanout/webhook.py` — Webhook fanout module
- `app/fanout/apprise_mod.py` — Apprise fanout module
- `app/fanout/sqs.py` — Amazon SQS fanout module
- `app/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, get_fanout_message_text
from app.path_utils import split_path_hex
logger = logging.getLogger(__name__)
def _parse_urls(raw: str) -> list[str]:
"""Split multi-line URL string into individual URLs."""
return [line.strip() for line in raw.splitlines() if line.strip()]
def _normalize_discord_url(url: str) -> str:
"""Add avatar=no to Discord URLs to suppress identity override."""
parts = urlsplit(url)
scheme = parts.scheme.lower()
host = parts.netloc.lower()
is_discord = scheme in ("discord", "discords") or (
scheme in ("http", "https")
and host in ("discord.com", "discordapp.com")
and parts.path.lower().startswith("/api/webhooks/")
)
if not is_discord:
return url
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["avatar"] = "no"
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _format_body(data: dict, *, include_path: bool) -> str:
"""Build a human-readable notification body from message data."""
msg_type = data.get("type", "")
text = get_fanout_message_text(data)
sender_name = data.get("sender_name") or "Unknown"
via = ""
if include_path:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
first_path = paths[0] if isinstance(paths[0], dict) else {}
path_str = first_path.get("path", "")
path_len = first_path.get("path_len")
else:
path_str = None
path_len = None
if msg_type == "PRIV" and path_str is None:
via = " **via:** [`direct`]"
elif path_str is not None:
path_str = path_str.strip().lower()
if path_str == "":
via = " **via:** [`direct`]"
else:
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
hops = split_path_hex(path_str, hop_count)
if hops:
hop_list = ", ".join(f"`{h}`" for h in hops)
via = f" **via:** [{hop_list}]"
if msg_type == "PRIV":
return f"**DM:** {sender_name}: {text}{via}"
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
return f"**{channel_name}:** {sender_name}: {text}{via}"
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
"""Send notification synchronously via Apprise. Returns True on success."""
import apprise as apprise_lib
urls = _parse_urls(urls_raw)
if not urls:
return False
notifier = apprise_lib.Apprise()
for url in urls:
if preserve_identity:
url = _normalize_discord_url(url)
notifier.add(url)
return bool(notifier.notify(title="", body=body))
class AppriseModule(FanoutModule):
"""Sends push notifications via Apprise for incoming messages."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
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"

62
app/fanout/base.py Normal file
View File

@@ -0,0 +1,62 @@
"""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
def get_fanout_message_text(data: dict) -> str:
"""Return the best human-readable message body for fanout consumers.
Channel messages are stored with the rendered sender label embedded in the
text (for example ``"Alice: hello"``). Human-facing integrations that also
carry ``sender_name`` should strip that duplicated prefix when it matches
the payload sender exactly.
"""
text = data.get("text", "")
if not isinstance(text, str):
return ""
if data.get("type") != "CHAN":
return text
sender_name = data.get("sender_name")
if not isinstance(sender_name, str) or not sender_name:
return text
prefix, separator, remainder = text.partition(": ")
if separator and prefix == sender_name:
return remainder
return text

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

@@ -0,0 +1,179 @@
"""Fanout module wrapping bot execution logic."""
from __future__ import annotations
import asyncio
import logging
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
def _derive_path_bytes_per_hop(paths: object, path_value: str | None) -> int | None:
"""Derive hop width from the first serialized message path when possible."""
if not isinstance(path_value, str) or not path_value:
return None
if not isinstance(paths, list) or not paths:
return None
first_path = paths[0]
if not isinstance(first_path, dict):
return None
path_hops = first_path.get("path_len")
if not isinstance(path_hops, int) or path_hops <= 0:
return None
path_hex_chars = len(path_value)
if path_hex_chars % 2 != 0:
return None
path_bytes = path_hex_chars // 2
if path_bytes % path_hops != 0:
return None
hop_width = path_bytes // path_hops
if hop_width not in (1, 2, 3):
return None
return hop_width
class BotModule(FanoutModule):
"""Wraps a single bot's code execution and response routing.
Each BotModule represents one bot configuration. It receives decoded
messages via ``on_message``, executes the bot's Python code in a
background task (after a 2-second settle delay), and sends any response
back through the radio.
"""
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
super().__init__(config_id, config, name=name)
self._tasks: set[asyncio.Task] = set()
self._active = True
async def stop(self) -> None:
self._active = False
for task in self._tasks:
task.cancel()
# Wait briefly for tasks to acknowledge cancellation
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
async def on_message(self, data: dict) -> None:
"""Kick off bot execution in a background task so we don't block dispatch."""
task = asyncio.create_task(self._run_for_message(data))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _run_for_message(self, data: dict) -> None:
from app.fanout.bot_exec import (
BOT_EXECUTION_TIMEOUT,
execute_bot_code,
process_bot_response,
)
code = self.config.get("code", "")
if not code or not code.strip():
return
msg_type = data.get("type", "")
is_dm = msg_type == "PRIV"
conversation_key = data.get("conversation_key", "")
logger.debug(
"Bot '%s' starting for type=%s conversation=%s outgoing=%s",
self.name,
msg_type or "unknown",
conversation_key[:12] if conversation_key else "(none)",
bool(data.get("outgoing", False)),
)
# Extract bot parameters from broadcast data
if is_dm:
sender_key = data.get("sender_key") or conversation_key
is_outgoing = data.get("outgoing", False)
message_text = data.get("text", "")
channel_key = None
channel_name = None
# Outgoing DMs: sender is us, not the contact
if is_outgoing:
sender_name = None
else:
sender_name = data.get("sender_name")
if sender_name is None:
from app.repository import ContactRepository
contact = await ContactRepository.get_by_key(conversation_key)
sender_name = contact.name if contact else None
else:
sender_key = None
is_outgoing = bool(data.get("outgoing", False))
sender_name = data.get("sender_name")
channel_key = conversation_key
channel_name = data.get("channel_name")
if channel_name is None:
from app.repository import ChannelRepository
channel = await ChannelRepository.get_by_key(conversation_key)
channel_name = channel.name if channel else None
# Strip "sender: " prefix from channel message text
text = data.get("text", "")
if sender_name and text.startswith(f"{sender_name}: "):
message_text = text[len(f"{sender_name}: ") :]
else:
message_text = text
sender_timestamp = data.get("sender_timestamp")
path_value = data.get("path")
paths = data.get("paths")
# Message model serializes paths as list of dicts; extract first path string
if path_value is None and paths and isinstance(paths, list) and len(paths) > 0:
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
# Wait for message to settle (allows retransmissions to be deduped)
await asyncio.sleep(2)
# Execute bot code in thread pool with timeout
from app.fanout.bot_exec import _bot_executor, _bot_semaphore
async with _bot_semaphore:
loop = asyncio.get_running_loop()
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path_value,
is_outgoing,
path_bytes_per_hop,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("Bot '%s' execution timed out", self.name)
return
except Exception:
logger.exception("Bot '%s' execution error", self.name)
return
if response and self._active:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
@property
def status(self) -> str:
return "connected"

View File

@@ -15,6 +15,7 @@ import inspect
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any
from fastapi import HTTPException
@@ -39,6 +40,102 @@ _bot_send_lock = asyncio.Lock()
_last_bot_send_time: float = 0.0
@dataclass(frozen=True)
class BotCallPlan:
"""How to call a validated bot() function."""
call_style: str
keyword_args: tuple[str, ...] = ()
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
"""Validate bot() signature and return a supported call plan."""
try:
sig = (
bot_func_or_sig
if isinstance(bot_func_or_sig, inspect.Signature)
else inspect.signature(bot_func_or_sig)
)
except (ValueError, TypeError) as exc:
raise ValueError("Bot function signature could not be inspected") from exc
params = sig.parameters
param_values = tuple(params.values())
positional_params = [
p
for p in param_values
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
explicit_optional_names = tuple(
name for name in ("is_outgoing", "path_bytes_per_hop") if name in params
)
unsupported_required_kwonly = [
p.name
for p in param_values
if p.kind == inspect.Parameter.KEYWORD_ONLY
and p.default is inspect.Parameter.empty
and p.name not in {"is_outgoing", "path_bytes_per_hop"}
]
if unsupported_required_kwonly:
raise ValueError(
"Bot function signature is not supported. Unsupported required keyword-only "
"parameters: " + ", ".join(unsupported_required_kwonly)
)
positional_capacity = len(positional_params)
base_args = [object()] * 8
base_keyword_args: dict[str, object] = {
"sender_name": object(),
"sender_key": object(),
"message_text": object(),
"is_dm": object(),
"channel_key": object(),
"channel_name": object(),
"sender_timestamp": object(),
"path": object(),
}
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
keyword_args = dict(base_keyword_args)
if has_kwargs or "is_outgoing" in params:
keyword_args["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
keyword_args["path_bytes_per_hop"] = 1
candidate_specs.append(("keyword", [], keyword_args))
if not has_kwargs and explicit_optional_names:
kwargs: dict[str, object] = {}
if has_kwargs or "is_outgoing" in params:
kwargs["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
kwargs["path_bytes_per_hop"] = 1
candidate_specs.append(("mixed_keyword", base_args, kwargs))
if has_varargs or positional_capacity >= 10:
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
if has_varargs or positional_capacity >= 9:
candidate_specs.append(("positional_9", base_args + [False], {}))
if has_varargs or positional_capacity >= 8:
candidate_specs.append(("legacy", base_args, {}))
for call_style, args, kwargs in candidate_specs:
try:
sig.bind(*args, **kwargs)
except TypeError:
continue
if call_style in {"keyword", "mixed_keyword"}:
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
return BotCallPlan(call_style=call_style)
raise ValueError(
"Bot function signature is not supported. Use the default bot template as a reference. "
"Supported trailing parameters are: path; path + is_outgoing; "
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
"or use **kwargs for forward compatibility."
)
def execute_bot_code(
code: str,
sender_name: str | None,
@@ -50,17 +147,19 @@ def execute_bot_code(
sender_timestamp: int | None,
path: str | None,
is_outgoing: bool = False,
path_bytes_per_hop: int | None = None,
) -> str | list[str] | None:
"""
Execute user-provided bot code with message context.
The code should define a function:
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop)`
or use named parameters / `**kwargs`.
that returns either None (no response), a string (single response message),
or a list of strings (multiple messages sent in order).
Legacy bot functions with 8 parameters (without is_outgoing) are detected
via inspect and called without the new parameter for backward compatibility.
Legacy bot functions with older signatures are detected via inspect and
called without the newer parameters for backward compatibility.
Args:
code: Python code defining the bot function
@@ -73,6 +172,7 @@ def execute_bot_code(
sender_timestamp: Sender's timestamp from the message (may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
Returns:
Response string, list of strings, or None.
@@ -90,8 +190,8 @@ def execute_bot_code(
try:
# Execute the user's code to define the bot function
exec(code, namespace)
except Exception as e:
logger.warning("Bot code compilation failed: %s", e)
except Exception:
logger.exception("Bot code compilation failed")
return None
# Check if bot function was defined
@@ -100,30 +200,28 @@ def execute_bot_code(
return None
bot_func = namespace["bot"]
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
# or uses the legacy 8-param signature, for backward compatibility.
# Three cases: explicit is_outgoing param or 9+ params (positional),
# **kwargs (pass as keyword), or legacy 8-param (omit).
call_style = "legacy" # "positional", "keyword", or "legacy"
try:
sig = inspect.signature(bot_func)
params = sig.parameters
non_variadic = [
p
for p in params.values()
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
]
if "is_outgoing" in params or len(non_variadic) >= 9:
call_style = "positional"
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
call_style = "keyword"
except (ValueError, TypeError):
pass
call_plan = _analyze_bot_signature(bot_func)
except ValueError as exc:
logger.error("%s", exc)
return None
try:
# Call the bot function with appropriate signature
if call_style == "positional":
if call_plan.call_style == "positional_10":
result = bot_func(
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
)
elif call_plan.call_style == "positional_9":
result = bot_func(
sender_name,
sender_key,
@@ -135,18 +233,29 @@ def execute_bot_code(
path,
is_outgoing,
)
elif call_style == "keyword":
result = bot_func(
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing=is_outgoing,
)
elif call_plan.call_style == "keyword":
keyword_args: dict[str, Any] = {}
if "sender_name" in call_plan.keyword_args:
keyword_args["sender_name"] = sender_name
if "sender_key" in call_plan.keyword_args:
keyword_args["sender_key"] = sender_key
if "message_text" in call_plan.keyword_args:
keyword_args["message_text"] = message_text
if "is_dm" in call_plan.keyword_args:
keyword_args["is_dm"] = is_dm
if "channel_key" in call_plan.keyword_args:
keyword_args["channel_key"] = channel_key
if "channel_name" in call_plan.keyword_args:
keyword_args["channel_name"] = channel_name
if "sender_timestamp" in call_plan.keyword_args:
keyword_args["sender_timestamp"] = sender_timestamp
if "path" in call_plan.keyword_args:
keyword_args["path"] = path
if "is_outgoing" in call_plan.keyword_args:
keyword_args["is_outgoing"] = is_outgoing
if "path_bytes_per_hop" in call_plan.keyword_args:
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
result = bot_func(**keyword_args)
else:
result = bot_func(
sender_name,
@@ -172,8 +281,8 @@ def execute_bot_code(
logger.debug("Bot function returned unsupported type: %s", type(result))
return None
except Exception as e:
logger.warning("Bot function execution failed: %s", e)
except Exception:
logger.exception("Bot function execution failed")
return None
@@ -249,102 +358,11 @@ async def _send_single_bot_message(
logger.warning("Cannot send bot response: no destination")
return # Don't update timestamp if we didn't send
except HTTPException as e:
logger.error("Bot failed to send response: %s", e.detail)
logger.error("Bot failed to send response: %s", e.detail, exc_info=True)
return # Don't update timestamp on failure
except Exception as e:
logger.error("Bot failed to send response: %s", e)
except Exception:
logger.exception("Bot failed to send response")
return # Don't update timestamp on failure
# Update last send time after successful send
_last_bot_send_time = time.monotonic()
async def run_bot_for_message(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None = None,
sender_timestamp: int | None = None,
path: str | None = None,
is_outgoing: bool = False,
) -> None:
"""
Run all enabled bots for a message (incoming or outgoing).
This is the main entry point called by message handlers after
a message is successfully decrypted and stored. Bots run serially,
and errors in one bot don't prevent others from running.
Args:
sender_name: Display name of the sender
sender_key: 64-char hex public key of sender (DMs only, None for channels)
message_text: The message content
is_dm: True for direct messages, False for channel messages
channel_key: Channel key for channel messages
channel_name: Channel name (e.g. "#general"), None for DMs
sender_timestamp: Sender's timestamp from the message
path: Hex-encoded routing path
is_outgoing: Whether this is our own outgoing message
"""
# Early check if any bots are enabled (will re-check after sleep)
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
return
async with _bot_semaphore:
logger.debug(
"Running %d bot(s) for message from %s (is_dm=%s)",
len(enabled_bots),
sender_name or (sender_key[:12] if sender_key else "unknown"),
is_dm,
)
# Wait for the initiating message's retransmissions to propagate through the mesh
await asyncio.sleep(2)
# Re-check settings after sleep (user may have changed bot config)
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
logger.debug("All bots disabled during wait, skipping")
return
# Run each enabled bot serially
loop = asyncio.get_event_loop()
for bot in enabled_bots:
logger.debug("Executing bot '%s'", bot.name)
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
bot.code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning(
"Bot '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
)
continue # Continue to next bot
except Exception as e:
logger.warning("Bot '%s' execution error: %s", bot.name, e)
continue # Continue to next bot
# Send response if any
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)

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 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
from app.version_info import get_app_build_info
logger = logging.getLogger(__name__)
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
_CLIENT_ID = "RemoteTerm"
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
# Periodic status republish interval (matches meshcore-packet-capture reference)
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
# 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": _get_client_version(),
}
if email:
payload["email"] = email
header_b64 = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = _base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
scalar = private_key[:32]
prefix = private_key[32:]
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
return f"{header_b64}.{payload_b64}.{signature.hex()}"
def _calculate_packet_hash(raw_bytes: bytes) -> str:
"""Calculate packet hash matching MeshCore's Packet::calculatePacketHash().
Parses the packet structure to extract payload type and payload data,
then hashes: payload_type(1 byte) [+ path_len(2 bytes LE) for TRACE] + payload_data.
Returns first 16 hex characters (uppercase).
"""
if not raw_bytes:
return "0" * 16
try:
envelope = parse_packet_envelope(raw_bytes)
if envelope is None:
return "0" * 16
# Hash: payload_type(1 byte) [+ path_byte as uint16_t LE for TRACE] + payload_data
# IMPORTANT: TRACE hash uses the raw wire byte (not decoded hop count) to match firmware.
hash_obj = hashlib.sha256()
hash_obj.update(bytes([envelope.payload_type]))
if envelope.payload_type == 9: # PAYLOAD_TYPE_TRACE
hash_obj.update(envelope.path_byte.to_bytes(2, byteorder="little"))
hash_obj.update(envelope.payload)
return hash_obj.hexdigest()[:16].upper()
except Exception:
return "0" * 16
def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], int | None]:
"""Decode packet fields used by the community uploader payload format.
Returns:
(route_letter, packet_type_str, payload_len_str, path_values, payload_type_int)
"""
# Reference defaults when decode fails
route = "U"
packet_type = "0"
payload_len = "0"
path_values: list[str] = []
payload_type: int | None = None
try:
envelope = parse_packet_envelope(raw_bytes)
if envelope is None or envelope.payload_version != 0:
return route, packet_type, payload_len, path_values, payload_type
payload_type = envelope.payload_type
route = _ROUTE_MAP.get(envelope.route_type, "U")
packet_type = str(payload_type)
payload_len = str(len(envelope.payload))
path_values = split_path_hex(envelope.path.hex(), envelope.hop_count)
return route, packet_type, payload_len, path_values, payload_type
except Exception:
return route, packet_type, payload_len, path_values, payload_type
def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: str) -> dict:
"""Convert a RawPacketBroadcast dict to meshcore-packet-capture format."""
raw_hex = data.get("data", "")
raw_bytes = bytes.fromhex(raw_hex) if raw_hex else b""
route, packet_type, payload_len, path_values, _payload_type = _decode_packet_fields(raw_bytes)
# Reference format uses local "now" timestamp and derived time/date fields.
current_time = datetime.now()
ts_str = current_time.isoformat()
# SNR/RSSI are always strings in reference output.
snr_val = data.get("snr")
rssi_val = data.get("rssi")
snr = str(snr_val) if snr_val is not None else "Unknown"
rssi = str(rssi_val) if rssi_val is not None else "Unknown"
packet_hash = _calculate_packet_hash(raw_bytes)
packet = {
"origin": device_name or "MeshCore Device",
"origin_id": public_key_hex.upper(),
"timestamp": ts_str,
"type": "PACKET",
"direction": "rx",
"time": current_time.strftime("%H:%M:%S"),
"date": current_time.strftime("%d/%m/%Y"),
"len": str(len(raw_bytes)),
"packet_type": packet_type,
"route": route,
"payload_len": payload_len,
"raw": raw_hex.upper(),
"SNR": snr,
"RSSI": rssi,
"hash": packet_hash,
}
if route == "D":
packet["path"] = ",".join(path_values)
return packet
def _build_status_topic(settings: CommunityMqttSettings, pubkey_hex: str) -> str:
"""Build the ``meshcore/{IATA}/{PUBKEY}/status`` topic string."""
iata = settings.community_mqtt_iata.upper().strip()
return f"meshcore/{iata}/{pubkey_hex}/status"
def _build_radio_info() -> str:
"""Format the radio parameters string from self_info.
Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw
values). Falls back to ``"0,0,0,0"`` when unavailable.
"""
from app.services.radio_runtime import radio_runtime as radio_manager
try:
if radio_manager.meshcore and radio_manager.meshcore.self_info:
info = radio_manager.meshcore.self_info
freq = info.get("radio_freq", 0)
bw = info.get("radio_bw", 0)
sf = info.get("radio_sf", 0)
cr = info.get("radio_cr", 0)
return f"{freq},{bw},{sf},{cr}"
except Exception:
pass
return "0,0,0,0"
def _get_client_version() -> str:
"""Return the canonical client/version identifier for community MQTT."""
build = get_app_build_info()
commit_hash = build.commit_hash or "unknown"
return f"{_CLIENT_ID}/{build.version}-{commit_hash}"
class CommunityMqttPublisher(BaseMqttPublisher):
"""Manages the community MQTT connection and publishes raw packets."""
_backoff_max = 60
_log_prefix = "Community MQTT"
_not_configured_timeout: float | None = 30
def __init__(self) -> None:
super().__init__()
self._key_unavailable_warned: bool = False
self._cached_device_info: dict[str, str] | None = None
self._cached_stats: dict[str, Any] | None = None
self._stats_supported: bool | None = None
self._last_stats_fetch: float = 0.0
self._last_status_publish: float = 0.0
async def start(self, settings: object) -> None:
self._key_unavailable_warned = False
self._cached_device_info = None
self._cached_stats = None
self._stats_supported = None
self._last_stats_fetch = 0.0
self._last_status_publish = 0.0
await super().start(settings)
def _on_not_configured(self) -> None:
from app.keystore import get_public_key, has_private_key
from app.websocket import broadcast_error
s: CommunityMqttSettings | None = self._settings
auth_mode = getattr(s, "community_mqtt_auth_mode", "token") if s else "token"
if (
s
and auth_mode == "token"
and get_public_key() is not None
and not has_private_key()
and not self._key_unavailable_warned
):
broadcast_error(
"Community MQTT unavailable",
"Radio firmware does not support private key export.",
)
self._key_unavailable_warned = True
def _is_configured(self) -> bool:
"""Check if community MQTT is enabled and keys are available."""
from app.keystore import get_public_key, has_private_key
s: CommunityMqttSettings | None = self._settings
if not s or not s.community_mqtt_enabled:
return False
if get_public_key() is None:
return False
auth_mode = getattr(s, "community_mqtt_auth_mode", "token")
if auth_mode == "token":
return has_private_key()
return True
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
from app.keystore import get_private_key, get_public_key
from app.services.radio_runtime import radio_runtime as radio_manager
private_key = get_private_key()
public_key = get_public_key()
assert public_key is not None # guaranteed by _pre_connect
pubkey_hex = public_key.hex().upper()
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
transport = s.community_mqtt_transport or "websockets"
use_tls = bool(s.community_mqtt_use_tls)
tls_verify = bool(s.community_mqtt_tls_verify)
auth_mode = s.community_mqtt_auth_mode or "token"
secure_connection = use_tls and tls_verify
tls_context: ssl.SSLContext | None = None
if use_tls:
tls_context = ssl.create_default_context()
if not tls_verify:
tls_context.check_hostname = False
tls_context.verify_mode = ssl.CERT_NONE
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
status_topic = _build_status_topic(s, pubkey_hex)
offline_payload = json.dumps(
{
"status": "offline",
"timestamp": datetime.now().isoformat(),
"origin": device_name or "MeshCore Device",
"origin_id": pubkey_hex,
}
)
kwargs: dict[str, Any] = {
"hostname": broker_host,
"port": broker_port,
"transport": transport,
"tls_context": tls_context,
"will": aiomqtt.Will(status_topic, offline_payload, retain=True),
}
if auth_mode == "token":
assert private_key is not None
token_audience = (s.community_mqtt_token_audience or "").strip() or broker_host
jwt_token = _generate_jwt_token(
private_key,
public_key,
audience=token_audience,
email=(s.community_mqtt_email or "") if secure_connection else "",
)
kwargs["username"] = f"v1_{pubkey_hex}"
kwargs["password"] = jwt_token
elif auth_mode == "password":
kwargs["username"] = s.community_mqtt_username or None
kwargs["password"] = s.community_mqtt_password or None
if transport == "websockets":
kwargs["websocket_path"] = "/"
return kwargs
def _on_connected(self, settings: object) -> tuple[str, str]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
async def _fetch_device_info(self) -> dict[str, str]:
"""Fetch firmware model/version from the radio (cached for the connection)."""
if self._cached_device_info is not None:
return self._cached_device_info
from app.radio import RadioDisconnectedError, RadioOperationBusyError
from app.services.radio_runtime import radio_runtime as radio_manager
fallback = {"model": "unknown", "firmware_version": "unknown"}
try:
async with radio_manager.radio_operation(
"community_stats_device_info", blocking=False
) as mc:
event = await mc.commands.send_device_query()
from meshcore.events import EventType
if event.type == EventType.DEVICE_INFO:
fw_ver = event.payload.get("fw ver", 0)
if fw_ver >= 3:
model = event.payload.get("model", "unknown") or "unknown"
ver = event.payload.get("ver", "unknown") or "unknown"
fw_build = event.payload.get("fw_build", "") or ""
fw_str = f"v{ver} (Build: {fw_build})" if fw_build else f"v{ver}"
self._cached_device_info = {
"model": model,
"firmware_version": fw_str,
}
else:
# Old firmware — cache what we can
self._cached_device_info = {
"model": "unknown",
"firmware_version": f"v{fw_ver}" if fw_ver else "unknown",
}
return self._cached_device_info
except (RadioOperationBusyError, RadioDisconnectedError):
pass
except Exception as e:
logger.debug("Community MQTT: device info fetch failed: %s", e)
# Don't cache transient failures — allow retry on next status publish
return fallback
async def _fetch_stats(self) -> dict[str, Any] | None:
"""Fetch core + radio stats from the radio (best-effort, cached)."""
if self._stats_supported is False:
return self._cached_stats
now = time.monotonic()
if (
now - self._last_stats_fetch
) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None:
return self._cached_stats
from app.radio import RadioDisconnectedError, RadioOperationBusyError
from app.services.radio_runtime import radio_runtime as radio_manager
try:
async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc:
from meshcore.events import EventType
result: dict[str, Any] = {}
core_event = await mc.commands.get_stats_core()
if core_event.type == EventType.ERROR:
logger.info("Community MQTT: firmware does not support stats commands")
self._stats_supported = False
return self._cached_stats
if core_event.type == EventType.STATS_CORE:
result.update(core_event.payload)
radio_event = await mc.commands.get_stats_radio()
if radio_event.type == EventType.ERROR:
logger.info("Community MQTT: firmware does not support stats commands")
self._stats_supported = False
return self._cached_stats
if radio_event.type == EventType.STATS_RADIO:
result.update(radio_event.payload)
if result:
self._cached_stats = result
self._last_stats_fetch = now
return self._cached_stats
except (RadioOperationBusyError, RadioDisconnectedError):
pass
except Exception as e:
logger.debug("Community MQTT: stats fetch failed: %s", e)
return self._cached_stats
async def _publish_status(
self, settings: CommunityMqttSettings, *, refresh_stats: bool = True
) -> None:
"""Build and publish the enriched retained status message."""
from app.keystore import get_public_key
from app.services.radio_runtime import radio_runtime as radio_manager
public_key = get_public_key()
if public_key is None:
return
pubkey_hex = public_key.hex().upper()
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
device_info = await self._fetch_device_info()
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
status_topic = _build_status_topic(settings, pubkey_hex)
payload: dict[str, Any] = {
"status": "online",
"timestamp": datetime.now().isoformat(),
"origin": device_name or "MeshCore Device",
"origin_id": pubkey_hex,
"model": device_info.get("model", "unknown"),
"firmware_version": device_info.get("firmware_version", "unknown"),
"radio": _build_radio_info(),
"client_version": _get_client_version(),
}
if stats:
payload["stats"] = stats
await self.publish(status_topic, payload, retain=True)
self._last_status_publish = time.monotonic()
async def _on_connected_async(self, settings: object) -> None:
"""Publish a retained online status message after connecting."""
await self._publish_status(settings) # type: ignore[arg-type]
async def _on_periodic_wake(self, elapsed: float) -> None:
if not self._settings:
return
now = time.monotonic()
if (now - self._last_status_publish) >= _STATS_REFRESH_INTERVAL:
await self._publish_status(self._settings, refresh_stats=True)
def _on_error(self) -> tuple[str, str]:
return (
"Community MQTT connection failure",
"Check your internet connection or try again later.",
)
def _should_break_wait(self, elapsed: float) -> bool:
if not self.connected:
logger.info("Community MQTT publish failure detected, reconnecting")
return True
s: CommunityMqttSettings | None = self._settings
auth_mode = getattr(s, "community_mqtt_auth_mode", "token") if s else "token"
if auth_mode == "token" and elapsed >= _TOKEN_RENEWAL_THRESHOLD:
logger.info("Community MQTT JWT nearing expiry, reconnecting")
return True
return False
async def _pre_connect(self, settings: object) -> bool:
from app.keystore import get_private_key, get_public_key
s: CommunityMqttSettings = settings # type: ignore[assignment]
auth_mode = s.community_mqtt_auth_mode or "token"
private_key = get_private_key()
public_key = get_public_key()
if public_key is None or (auth_mode == "token" and private_key is None):
# Keys not available yet, wait for settings change or key export
self.connected = False
self._version_event.clear()
try:
await asyncio.wait_for(self._version_event.wait(), timeout=30)
except asyncio.TimeoutError:
pass
return False
return True

245
app/fanout/manager.py Normal file
View File

@@ -0,0 +1,245 @@
"""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.sqs import SqsModule
from app.fanout.webhook import WebhookModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
_MODULE_TYPES["bot"] = BotModule
_MODULE_TYPES["webhook"] = WebhookModule
_MODULE_TYPES["apprise"] = AppriseModule
_MODULE_TYPES["sqs"] = SqsModule
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"

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

@@ -0,0 +1,253 @@
"""Shared base class for MQTT publisher lifecycle management.
Both ``MqttPublisher`` (private broker) and ``CommunityMqttPublisher``
(community aggregator) inherit from ``BaseMqttPublisher``, which owns
the connection-loop skeleton, reconnect/backoff logic, and publish method.
Subclasses override a small set of hooks to control configuration checks,
client construction, toast messages, and optional wait-loop behavior.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from abc import ABC, abstractmethod
from typing import Any
import aiomqtt
logger = logging.getLogger(__name__)
_BACKOFF_MIN = 5
def _broadcast_health() -> None:
"""Push updated health (including MQTT status) to all WS clients."""
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_health
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
class BaseMqttPublisher(ABC):
"""Base class for MQTT publishers with shared lifecycle management.
Subclasses implement the abstract hooks to control configuration checks,
client construction, toast messages, and optional wait-loop behavior.
The settings type is duck-typed — each subclass defines a Protocol
describing the attributes it expects (e.g. ``PrivateMqttSettings``,
``CommunityMqttSettings``). Callers pass ``SimpleNamespace`` instances
that satisfy the protocol.
"""
_backoff_max: int = 30
_log_prefix: str = "MQTT"
_not_configured_timeout: float | None = None # None = block forever
def __init__(self) -> None:
self._client: aiomqtt.Client | None = None
self._task: asyncio.Task[None] | None = None
self._settings: Any = None
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
self.integration_name: str = ""
def set_integration_name(self, name: str) -> None:
"""Attach the configured fanout-module name for operator-facing logs."""
self.integration_name = name.strip()
def _integration_label(self) -> str:
"""Return a concise label for logs, including the configured module name."""
if self.integration_name:
return f"{self._log_prefix} [{self.integration_name}]"
return self._log_prefix
# ── 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. This is usually transient network noise; "
"if it self-resolves and reconnects, it is generally not a concern: %s",
self._integration_label(),
topic,
e,
exc_info=True,
)
self.connected = False
# Wake the connection loop so it exits the wait and reconnects
self._settings_version += 1
self._version_event.set()
# ── Abstract hooks ─────────────────────────────────────────────────
@abstractmethod
def _is_configured(self) -> bool:
"""Return True when this publisher should attempt to connect."""
@abstractmethod
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
@abstractmethod
def _on_connected(self, settings: object) -> tuple[str, str]:
"""Return ``(title, detail)`` for the success toast on connect."""
@abstractmethod
def _on_error(self) -> tuple[str, str]:
"""Return ``(title, detail)`` for the error toast on connect failure."""
# ── Optional hooks ─────────────────────────────────────────────────
def _should_break_wait(self, elapsed: float) -> bool:
"""Return True to break the inner wait (e.g. token expiry)."""
return False
async def _pre_connect(self, settings: object) -> bool:
"""Called before connecting. Return True to proceed, False to retry."""
return True
def _on_not_configured(self) -> None:
"""Called each time the loop finds the publisher not configured."""
return # no-op by default; subclasses may override
async def _on_connected_async(self, settings: object) -> None:
"""Async hook called after connection succeeds (before health broadcast).
Subclasses can override to publish messages immediately after connecting.
"""
return # no-op by default
async def _on_periodic_wake(self, elapsed: float) -> None:
"""Called every ~60s while connected. Subclasses may override."""
return
# ── Connection loop ────────────────────────────────────────────────
async def _connection_loop(self) -> None:
"""Background loop: connect, wait for version change, reconnect on failure."""
from app.websocket import broadcast_error, broadcast_success
backoff = _BACKOFF_MIN
while True:
if not self._is_configured():
self._on_not_configured()
self.connected = False
self._client = None
self._version_event.clear()
try:
if self._not_configured_timeout is None:
await self._version_event.wait()
else:
await asyncio.wait_for(
self._version_event.wait(),
timeout=self._not_configured_timeout,
)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
return
continue
settings = self._settings
assert settings is not None # guaranteed by _is_configured()
version_at_connect = self._settings_version
try:
if not await self._pre_connect(settings):
continue
client_kwargs = self._build_client_kwargs(settings)
connect_time = time.monotonic()
async with aiomqtt.Client(**client_kwargs) as client:
self._client = client
self.connected = True
backoff = _BACKOFF_MIN
title, detail = self._on_connected(settings)
broadcast_success(title, detail)
await self._on_connected_async(settings)
_broadcast_health()
# Wait until cancelled or settings version changes.
# The 60s timeout is a housekeeping wake-up; actual connection
# liveness is handled by paho-mqtt's keepalive mechanism.
while self._settings_version == version_at_connect:
self._version_event.clear()
try:
await asyncio.wait_for(self._version_event.wait(), timeout=60)
except asyncio.TimeoutError:
elapsed = time.monotonic() - connect_time
await self._on_periodic_wake(elapsed)
if self._should_break_wait(elapsed):
break
continue
# async with exited — client is now closed
self._client = None
self.connected = False
_broadcast_health()
except asyncio.CancelledError:
self.connected = False
self._client = None
return
except Exception as e:
self.connected = False
self._client = None
title, detail = self._on_error()
broadcast_error(title, detail)
_broadcast_health()
logger.warning(
"%s connection error. This is usually transient network noise; "
"if it self-resolves, it is generally not a concern: %s "
"(reconnecting in %ds)",
self._integration_label(),
e,
backoff,
exc_info=True,
)
try:
await asyncio.sleep(backoff)
except asyncio.CancelledError:
return
backoff = min(backoff * 2, self._backoff_max)

View File

@@ -0,0 +1,139 @@
"""Fanout module wrapping the community MQTT publisher."""
from __future__ import annotations
import logging
import re
import string
from types import SimpleNamespace
from typing import Any
from app.fanout.base import FanoutModule
from app.fanout.community_mqtt import CommunityMqttPublisher, _format_raw_packet
logger = logging.getLogger(__name__)
_IATA_RE = re.compile(r"^[A-Z]{3}$")
_DEFAULT_PACKET_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
_TOPIC_TEMPLATE_FIELD_CANONICAL = {
"iata": "IATA",
"public_key": "PUBLIC_KEY",
}
def _normalize_topic_template(topic_template: str) -> str:
"""Normalize packet topic template fields to canonical uppercase placeholders."""
template = topic_template.strip() or _DEFAULT_PACKET_TOPIC_TEMPLATE
parts: list[str] = []
try:
parsed = string.Formatter().parse(template)
for literal_text, field_name, format_spec, conversion in parsed:
parts.append(literal_text)
if field_name is None:
continue
normalized_field = _TOPIC_TEMPLATE_FIELD_CANONICAL.get(field_name.lower())
if normalized_field is None:
raise ValueError(f"Unsupported topic template field(s): {field_name}")
replacement = ["{", normalized_field]
if conversion:
replacement.extend(["!", conversion])
if format_spec:
replacement.extend([":", format_spec])
replacement.append("}")
parts.append("".join(replacement))
except ValueError:
raise
return "".join(parts)
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the CommunityMqttPublisher."""
return SimpleNamespace(
community_mqtt_enabled=True,
community_mqtt_broker_host=config.get("broker_host", "mqtt-us-v1.letsmesh.net"),
community_mqtt_broker_port=config.get("broker_port", 443),
community_mqtt_transport=config.get("transport", "websockets"),
community_mqtt_use_tls=config.get("use_tls", True),
community_mqtt_tls_verify=config.get("tls_verify", True),
community_mqtt_auth_mode=config.get("auth_mode", "token"),
community_mqtt_username=config.get("username", ""),
community_mqtt_password=config.get("password", ""),
community_mqtt_iata=config.get("iata", ""),
community_mqtt_email=config.get("email", ""),
community_mqtt_token_audience=config.get("token_audience", ""),
)
def _render_packet_topic(topic_template: str, *, iata: str, public_key: str) -> str:
"""Render the configured raw-packet publish topic."""
template = _normalize_topic_template(topic_template)
return template.format(IATA=iata, PUBLIC_KEY=public_key)
class MqttCommunityModule(FanoutModule):
"""Wraps a CommunityMqttPublisher for community packet sharing."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = CommunityMqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)
await self._publisher.start(settings)
async def stop(self) -> None:
await self._publisher.stop()
async def on_message(self, data: dict) -> None:
# Community MQTT only publishes raw packets, not decoded messages.
pass
async def on_raw(self, data: dict) -> None:
if not self._publisher.connected or self._publisher._settings is None:
return
await _publish_community_packet(self._publisher, self.config, data)
@property
def status(self) -> str:
if self._publisher._is_configured():
return "connected" if self._publisher.connected else "disconnected"
return "disconnected"
async def _publish_community_packet(
publisher: CommunityMqttPublisher,
config: dict,
data: dict[str, Any],
) -> None:
"""Format and publish a raw packet to the community broker."""
try:
from app.keystore import get_public_key
from app.services.radio_runtime import radio_runtime as radio_manager
public_key = get_public_key()
if public_key is None:
return
pubkey_hex = public_key.hex().upper()
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
packet = _format_raw_packet(data, device_name, pubkey_hex)
iata = config.get("iata", "").upper().strip()
if not _IATA_RE.fullmatch(iata):
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
return
topic = _render_packet_topic(
str(config.get("topic_template", _DEFAULT_PACKET_TOPIC_TEMPLATE)),
iata=iata,
public_key=pubkey_hex,
)
await publisher.publish(topic, packet)
except Exception as e:
logger.warning("Community MQTT broadcast error: %s", e, exc_info=True)

View File

@@ -0,0 +1,62 @@
"""Fanout module wrapping the private MQTT publisher."""
from __future__ import annotations
import logging
from types import SimpleNamespace
from app.fanout.base import FanoutModule
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
logger = logging.getLogger(__name__)
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the MqttPublisher."""
return SimpleNamespace(
mqtt_broker_host=config.get("broker_host", ""),
mqtt_broker_port=config.get("broker_port", 1883),
mqtt_username=config.get("username", ""),
mqtt_password=config.get("password", ""),
mqtt_use_tls=config.get("use_tls", False),
mqtt_tls_insecure=config.get("tls_insecure", False),
mqtt_topic_prefix=config.get("topic_prefix", "meshcore"),
mqtt_publish_messages=True,
mqtt_publish_raw_packets=True,
)
class MqttPrivateModule(FanoutModule):
"""Wraps an MqttPublisher instance for private MQTT forwarding."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = MqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)
await self._publisher.start(settings)
async def stop(self) -> None:
await self._publisher.stop()
async def on_message(self, data: dict) -> None:
if not self._publisher.connected or self._publisher._settings is None:
return
prefix = self.config.get("topic_prefix", "meshcore")
topic = _build_message_topic(prefix, data)
await self._publisher.publish(topic, data)
async def on_raw(self, data: dict) -> None:
if not self._publisher.connected or self._publisher._settings is None:
return
prefix = self.config.get("topic_prefix", "meshcore")
topic = _build_raw_packet_topic(prefix, data)
await self._publisher.publish(topic, data)
@property
def status(self) -> str:
if not self.config.get("broker_host"):
return "disconnected"
return "connected" if self._publisher.connected else "disconnected"

164
app/fanout/sqs.py Normal file
View File

@@ -0,0 +1,164 @@
"""Fanout module for Amazon SQS delivery."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from functools import partial
from urllib.parse import urlparse
import boto3
from botocore.exceptions import BotoCoreError, ClientError
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
def _build_payload(data: dict, *, event_type: str) -> str:
"""Serialize a fanout event into a stable JSON envelope."""
return json.dumps(
{
"event_type": event_type,
"data": data,
},
separators=(",", ":"),
sort_keys=True,
)
def _infer_region_from_queue_url(queue_url: str) -> str | None:
"""Infer AWS region from a standard SQS queue URL host when possible."""
host = urlparse(queue_url).hostname or ""
if not host:
return None
parts = host.split(".")
if len(parts) < 4 or parts[0] != "sqs":
return None
if parts[2] != "amazonaws":
return None
if parts[3] not in {"com", "com.cn"}:
return None
region = parts[1].strip()
return region or None
def _is_fifo_queue(queue_url: str) -> bool:
"""Return True when the configured queue URL points at an SQS FIFO queue."""
return queue_url.rstrip("/").endswith(".fifo")
def _build_message_group_id(data: dict, *, event_type: str) -> str:
"""Choose a stable FIFO group ID from the event identity."""
if event_type == "message":
conversation_key = str(data.get("conversation_key", "")).strip()
if conversation_key:
return f"message-{conversation_key}"
return "message-default"
return "raw-packets"
def _build_message_deduplication_id(data: dict, *, event_type: str, body: str) -> str:
"""Choose a deterministic deduplication ID for FIFO queues."""
if event_type == "message":
message_id = data.get("id")
if isinstance(message_id, int):
return f"message-{message_id}"
else:
observation_id = data.get("observation_id")
if isinstance(observation_id, str) and observation_id.strip():
return f"raw-{observation_id}"
packet_id = data.get("id")
if isinstance(packet_id, int):
return f"raw-{packet_id}"
return hashlib.sha256(body.encode()).hexdigest()
class SqsModule(FanoutModule):
"""Delivers message and raw-packet events to an Amazon SQS queue."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._client = None
self._last_error: str | None = None
async def start(self) -> None:
kwargs: dict[str, str] = {}
queue_url = str(self.config.get("queue_url", "")).strip()
region_name = str(self.config.get("region_name", "")).strip()
endpoint_url = str(self.config.get("endpoint_url", "")).strip()
access_key_id = str(self.config.get("access_key_id", "")).strip()
secret_access_key = str(self.config.get("secret_access_key", "")).strip()
session_token = str(self.config.get("session_token", "")).strip()
if not region_name:
region_name = _infer_region_from_queue_url(queue_url) or ""
if region_name:
kwargs["region_name"] = region_name
if endpoint_url:
kwargs["endpoint_url"] = endpoint_url
if access_key_id and secret_access_key:
kwargs["aws_access_key_id"] = access_key_id
kwargs["aws_secret_access_key"] = secret_access_key
if session_token:
kwargs["aws_session_token"] = session_token
self._client = boto3.client("sqs", **kwargs)
self._last_error = None
async def stop(self) -> None:
self._client = None
async def on_message(self, data: dict) -> None:
await self._send(data, event_type="message")
async def on_raw(self, data: dict) -> None:
await self._send(data, event_type="raw_packet")
async def _send(self, data: dict, *, event_type: str) -> None:
if self._client is None:
return
queue_url = str(self.config.get("queue_url", "")).strip()
if not queue_url:
return
body = _build_payload(data, event_type=event_type)
request_kwargs: dict[str, object] = {
"QueueUrl": queue_url,
"MessageBody": body,
"MessageAttributes": {
"event_type": {
"DataType": "String",
"StringValue": event_type,
}
},
}
if _is_fifo_queue(queue_url):
request_kwargs["MessageGroupId"] = _build_message_group_id(data, event_type=event_type)
request_kwargs["MessageDeduplicationId"] = _build_message_deduplication_id(
data, event_type=event_type, body=body
)
try:
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
self._last_error = None
except (ClientError, BotoCoreError) as exc:
self._last_error = str(exc)
logger.warning("SQS %s send error: %s", self.config_id, exc)
except Exception as exc:
self._last_error = str(exc)
logger.exception("Unexpected SQS send error for %s", self.config_id)
@property
def status(self) -> str:
if not str(self.config.get("queue_url", "")).strip():
return "disconnected"
if self._last_error:
return "error"
return "connected"

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"

View File

@@ -7,6 +7,36 @@ from fastapi.staticfiles import StaticFiles
logger = logging.getLogger(__name__)
INDEX_CACHE_CONTROL = "no-store"
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
FRONTEND_BUILD_INSTRUCTIONS = (
"Run 'cd frontend && npm install && npm run build', "
"or use a release zip that includes frontend/prebuilt."
)
class CacheControlStaticFiles(StaticFiles):
"""StaticFiles variant that adds a fixed Cache-Control header."""
def __init__(self, *args, cache_control: str, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.cache_control = cache_control
def file_response(self, *args, **kwargs):
response = super().file_response(*args, **kwargs)
response.headers["Cache-Control"] = self.cache_control
return response
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
return FileResponse(path, headers={"Cache-Control": cache_control})
def _is_index_file(path: Path, index_file: Path) -> bool:
"""Return True when the requested file is the SPA shell index.html."""
return path == index_file
def _resolve_request_origin(request: Request) -> str:
"""Resolve the external origin, honoring common reverse-proxy headers."""
@@ -22,42 +52,44 @@ def _resolve_request_origin(request: Request) -> str:
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.
"""
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
"""Resolve and validate a built frontend directory."""
frontend_dir = frontend_dir.resolve()
index_file = frontend_dir / "index.html"
if not frontend_dir.exists():
if log_failures:
logger.error("Frontend build directory not found at %s.", frontend_dir)
return False, frontend_dir
if not frontend_dir.is_dir():
if log_failures:
logger.error("Frontend build path is not a directory: %s.", frontend_dir)
return False, frontend_dir
if not index_file.exists():
if log_failures:
logger.error("Frontend index file not found at %s.", index_file)
return False, frontend_dir
return True, frontend_dir
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
"""Register frontend static file routes if a built frontend is available."""
valid, frontend_dir = _validate_frontend_dir(frontend_dir)
if not valid:
return False
index_file = frontend_dir / "index.html"
assets_dir = frontend_dir / "assets"
if not frontend_dir.exists():
logger.error(
"Frontend build directory not found at %s. "
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
frontend_dir,
)
return False
if not frontend_dir.is_dir():
logger.error(
"Frontend build path is not a directory: %s. "
"API will continue without frontend routes.",
frontend_dir,
)
return False
if not index_file.exists():
logger.error(
"Frontend index file not found at %s. "
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
index_file,
)
return False
if assets_dir.exists() and assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
app.mount(
"/assets",
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
name="assets",
)
else:
logger.warning(
"Frontend assets directory missing at %s; /assets files will not be served",
@@ -67,7 +99,7 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/")
async def serve_index():
"""Serve the frontend index.html."""
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
@app.get("/site.webmanifest")
async def serve_webmanifest(request: Request):
@@ -114,14 +146,43 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
raise HTTPException(status_code=404, detail="Not found") from None
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
cache_control = (
INDEX_CACHE_CONTROL
if _is_index_file(file_path, index_file)
else STATIC_FILE_CACHE_CONTROL
)
return _file_response(file_path, cache_control=cache_control)
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
logger.info("Serving frontend from %s", frontend_dir)
return True
def register_first_available_frontend_static_routes(
app: FastAPI, frontend_dirs: list[Path]
) -> Path | None:
"""Register frontend routes from the first valid build directory."""
for i, candidate in enumerate(frontend_dirs):
valid, resolved_candidate = _validate_frontend_dir(candidate, log_failures=False)
if not valid:
continue
if register_frontend_static_routes(app, resolved_candidate):
logger.info("Selected frontend build directory %s", resolved_candidate)
return resolved_candidate
if i < len(frontend_dirs) - 1:
logger.warning("Frontend build at %s was unusable; trying fallback", resolved_candidate)
logger.error(
"No usable frontend build found. Searched: %s. %s API will continue without frontend routes.",
", ".join(str(path.resolve()) for path in frontend_dirs),
FRONTEND_BUILD_INSTRUCTIONS,
)
return None
def register_frontend_missing_fallback(app: FastAPI) -> None:
"""Register a fallback route that tells the user to build the frontend."""
@@ -129,7 +190,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
async def frontend_not_built():
return JSONResponse(
status_code=404,
content={
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
},
content={"detail": f"Frontend not built. {FRONTEND_BUILD_INSTRUCTIONS}"},
)

View File

@@ -30,6 +30,16 @@ _private_key: bytes | None = None
_public_key: bytes | None = None
def clear_keys() -> None:
"""Clear any stored private/public key material from memory."""
global _private_key, _public_key
had_key = _private_key is not None or _public_key is not None
_private_key = None
_public_key = None
if had_key:
logger.info("Cleared in-memory keystore")
def set_private_key(key: bytes) -> None:
"""Store the private key in memory and derive the public key.

View File

@@ -1,125 +0,0 @@
"""Loopback transport: bridges a browser-side serial/BLE connection over WebSocket."""
import asyncio
import logging
from typing import Any, Literal
from starlette.websockets import WebSocket, WebSocketState
logger = logging.getLogger(__name__)
class LoopbackTransport:
"""ConnectionProtocol implementation that tunnels bytes over a WebSocket.
For serial mode, applies the same 0x3c + 2-byte LE size framing that
meshcore's SerialConnection uses. For BLE mode, passes raw bytes through
(matching BLEConnection behaviour).
"""
def __init__(self, websocket: WebSocket, mode: Literal["serial", "ble"]) -> None:
self._ws = websocket
self._mode = mode
self._reader: Any = None
self._disconnect_callback: Any = None
# Serial framing state (mirrors meshcore serial_cx.py handle_rx)
self._header = b""
self._inframe = b""
self._frame_started = False
self._frame_size = 0
# -- ConnectionProtocol methods ------------------------------------------
async def connect(self) -> str:
"""No-op — the WebSocket is already established."""
info = f"Loopback ({self._mode})"
logger.info("Loopback transport connected: %s", info)
return info
async def disconnect(self) -> None:
"""Ask the browser to release the hardware and close the WS."""
try:
if self._ws.client_state == WebSocketState.CONNECTED:
await self._ws.send_json({"type": "disconnect"})
except Exception:
pass # WS may already be closed
async def send(self, data: Any) -> None:
"""Send data to the browser (which writes it to the physical radio).
Serial mode: prepend 0x3c + 2-byte LE size header.
BLE mode: send raw bytes.
"""
try:
if self._ws.client_state != WebSocketState.CONNECTED:
return
if self._mode == "serial":
size = len(data)
pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + bytes(data)
await self._ws.send_bytes(pkt)
else:
await self._ws.send_bytes(bytes(data))
except Exception as e:
logger.debug("Loopback send error: %s", e)
def set_reader(self, reader: Any) -> None:
self._reader = reader
def set_disconnect_callback(self, callback: Any) -> None:
self._disconnect_callback = callback
# -- Incoming data from browser ------------------------------------------
def handle_rx(self, data: bytes) -> None:
"""Process bytes received from the browser.
Serial mode: accumulate bytes, strip framing, deliver payload.
BLE mode: deliver raw bytes directly.
"""
if self._mode == "serial":
self._handle_rx_serial(data)
else:
self._handle_rx_ble(data)
def _handle_rx_ble(self, data: bytes) -> None:
if self._reader is not None:
asyncio.create_task(self._reader.handle_rx(data))
def _handle_rx_serial(self, data: bytes) -> None:
"""Mirror meshcore's SerialConnection.handle_rx state machine."""
raw = bytes(data)
headerlen = len(self._header)
if not self._frame_started:
if len(raw) >= 3 - headerlen:
self._header = self._header + raw[: 3 - headerlen]
self._frame_started = True
self._frame_size = int.from_bytes(self._header[1:], byteorder="little")
remainder = raw[3 - headerlen :]
# Reset header for next frame
self._header = b""
if remainder:
self._handle_rx_serial(remainder)
else:
self._header = self._header + raw
else:
framelen = len(self._inframe)
if framelen + len(raw) < self._frame_size:
self._inframe = self._inframe + raw
else:
self._inframe = self._inframe + raw[: self._frame_size - framelen]
if self._reader is not None:
asyncio.create_task(self._reader.handle_rx(self._inframe))
remainder = raw[self._frame_size - framelen :]
self._frame_started = False
self._inframe = b""
if remainder:
self._handle_rx_serial(remainder)
def reset_framing(self) -> None:
"""Reset the serial framing state machine."""
self._header = b""
self._inframe = b""
self._frame_started = False
self._frame_size = 0

View File

@@ -1,15 +1,21 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
from app.radio import RadioDisconnectedError, radio_manager
from app.frontend_static import (
register_first_available_frontend_static_routes,
register_frontend_missing_fallback,
)
from app.radio import RadioDisconnectedError
from app.radio_sync import (
stop_message_polling,
stop_periodic_advert,
@@ -18,22 +24,39 @@ from app.radio_sync import (
from app.routers import (
channels,
contacts,
debug,
fanout,
health,
loopback,
messages,
packets,
radio,
read_state,
repeaters,
rooms,
settings,
statistics,
ws,
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
setup_logging()
logger = logging.getLogger(__name__)
async def _startup_radio_connect_and_setup() -> None:
"""Connect/setup the radio in the background so HTTP serving can start immediately."""
try:
connected = await radio_manager.reconnect_and_prepare(broadcast_on_success=True)
if connected:
logger.info("Connected to radio")
else:
logger.warning("Failed to connect to radio on startup")
except Exception:
logger.exception("Failed to connect to radio on startup")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage database and radio connection lifecycle."""
@@ -47,30 +70,30 @@ async def lifespan(app: FastAPI):
await ensure_default_channels()
try:
await radio_manager.connect()
logger.info("Connected to radio")
await radio_manager.post_connect_setup()
except Exception as e:
logger.warning("Failed to connect to radio on startup: %s", e)
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
# Start MQTT publisher if configured
from app.mqtt import mqtt_publisher
from app.repository import AppSettingsRepository
# Start fanout modules (MQTT, etc.) from database configs
from app.fanout.manager import fanout_manager
try:
mqtt_settings = await AppSettingsRepository.get()
await mqtt_publisher.start(mqtt_settings)
except Exception as e:
logger.warning("Failed to start MQTT publisher: %s", e)
await fanout_manager.load_from_db()
except Exception:
logger.exception("Failed to start fanout modules")
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
app.state.startup_radio_task = startup_radio_task
yield
logger.info("Shutting down")
await mqtt_publisher.stop()
if startup_radio_task and not startup_radio_task.done():
startup_radio_task.cancel()
try:
await startup_radio_task
except asyncio.CancelledError:
pass
await fanout_manager.stop_all()
await radio_manager.stop_connection_monitor()
await stop_message_polling()
await stop_periodic_advert()
@@ -81,25 +104,15 @@ 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=_get_version(),
version=get_app_build_info().version,
lifespan=lifespan,
)
add_optional_basic_auth_middleware(app, server_settings)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -117,9 +130,12 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
app.include_router(debug.router, prefix="/api")
app.include_router(fanout.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
app.include_router(repeaters.router, prefix="/api")
app.include_router(rooms.router, prefix="/api")
app.include_router(channels.router, prefix="/api")
app.include_router(messages.router, prefix="/api")
app.include_router(packets.router, prefix="/api")
@@ -127,9 +143,11 @@ 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")
app.include_router(loopback.router, prefix="/api")
# Serve frontend static files in production
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if not register_frontend_static_routes(app, FRONTEND_DIR):
FRONTEND_DIST_DIR = Path(__file__).parent.parent / "frontend" / "dist"
FRONTEND_PREBUILT_DIR = Path(__file__).parent.parent / "frontend" / "prebuilt"
if not register_first_available_frontend_static_routes(
app, [FRONTEND_DIST_DIR, FRONTEND_PREBUILT_DIR]
):
register_frontend_missing_fallback(app)

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,90 @@ from typing import Literal
from pydantic import BaseModel, Field
from app.path_utils import normalize_contact_route, normalize_route_override
class ContactRoute(BaseModel):
"""A normalized contact route."""
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
path_hash_mode: int = Field(
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
)
class ContactUpsert(BaseModel):
"""Typed write contract for contacts persisted to SQLite."""
public_key: str = Field(description="Public key (64-char hex)")
name: str | None = None
type: int = 0
flags: int = 0
direct_path: str | None = None
direct_path_len: int | None = None
direct_path_hash_mode: int | None = None
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
last_seen: int | None = None
on_radio: bool | None = None
last_contacted: int | None = None
first_seen: int | None = None
@classmethod
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
return cls.model_validate(
{
**contact.model_dump(exclude={"last_read_at"}),
**changes,
}
)
@classmethod
def from_radio_dict(
cls, public_key: str, radio_data: dict, on_radio: bool = False
) -> "ContactUpsert":
"""Convert radio contact data to the contact-row write shape."""
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
radio_data.get("out_path"),
radio_data.get("out_path_len", -1),
radio_data.get(
"out_path_hash_mode",
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
),
)
return cls(
public_key=public_key,
name=radio_data.get("adv_name"),
type=radio_data.get("type", 0),
flags=radio_data.get("flags", 0),
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
lat=radio_data.get("adv_lat"),
lon=radio_data.get("adv_lon"),
last_advert=radio_data.get("last_advert"),
on_radio=on_radio,
)
class Contact(BaseModel):
public_key: str = Field(description="Public key (64-char hex)")
name: str | None = None
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
direct_path: str | None = None
direct_path_len: int = -1
direct_path_hash_mode: int = -1
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
@@ -18,44 +94,116 @@ class Contact(BaseModel):
last_contacted: int | None = None # Last time we sent/received a message
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
effective_route: ContactRoute | None = None
effective_route_source: Literal["override", "direct", "flood"] = "flood"
direct_route: ContactRoute | None = None
route_override: ContactRoute | None = None
def model_post_init(self, __context) -> None:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
self.direct_path = direct_path or None
self.direct_path_len = direct_path_len
self.direct_path_hash_mode = direct_path_hash_mode
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
)
self.route_override_path = route_override_path or None
self.route_override_len = route_override_len
self.route_override_hash_mode = route_override_hash_mode
if (
route_override_path is not None
and route_override_len is not None
and route_override_hash_mode is not None
):
self.route_override = ContactRoute(
path=route_override_path,
path_len=route_override_len,
path_hash_mode=route_override_hash_mode,
)
else:
self.route_override = None
if direct_path_len >= 0:
self.direct_route = ContactRoute(
path=direct_path,
path_len=direct_path_len,
path_hash_mode=direct_path_hash_mode,
)
else:
self.direct_route = None
path, path_len, path_hash_mode = self.effective_route_tuple()
if self.has_route_override():
self.effective_route_source = "override"
elif self.direct_route is not None:
self.effective_route_source = "direct"
else:
self.effective_route_source = "flood"
self.effective_route = ContactRoute(
path=path,
path_len=path_len,
path_hash_mode=path_hash_mode,
)
def has_route_override(self) -> bool:
return self.route_override_len is not None
def effective_route_tuple(self) -> tuple[str, int, int]:
if self.has_route_override():
return normalize_contact_route(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
if self.direct_path_len >= 0:
return normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
return "", -1, -1
def to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands.
The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_path, etc.).
than our database schema (name, direct_path, etc.).
"""
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
return {
"public_key": self.public_key,
"adv_name": self.name or "",
"type": self.type,
"flags": self.flags,
"out_path": self.last_path or "",
"out_path_len": self.last_path_len,
"out_path": effective_path,
"out_path_len": effective_path_len,
"out_path_hash_mode": effective_path_hash_mode,
"adv_lat": self.lat if self.lat is not None else 0.0,
"adv_lon": self.lon if self.lon is not None else 0.0,
"last_advert": self.last_advert if self.last_advert is not None else 0,
}
def to_upsert(self, **changes) -> ContactUpsert:
"""Convert the stored contact to the repository's write contract."""
return ContactUpsert.from_contact(self, **changes)
@staticmethod
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
"""Convert radio contact data to database format dict.
This is the inverse of to_radio_dict(), used when syncing contacts
from radio to database.
"""
return {
"public_key": public_key,
"name": radio_data.get("adv_name"),
"type": radio_data.get("type", 0),
"flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1),
"lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"),
"on_radio": on_radio,
}
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
return ContactUpsert.from_radio_dict(
public_key,
radio_data,
on_radio=on_radio,
).model_dump()
class CreateContactRequest(BaseModel):
@@ -69,8 +217,21 @@ class CreateContactRequest(BaseModel):
)
class ContactRoutingOverrideRequest(BaseModel):
"""Request to set, force, or clear a contact routing override."""
route: str = Field(
description=(
"Blank clears the override, "
'"-1" forces flood, "0" forces direct, and explicit routes are '
"comma-separated 1/2/3-byte hop hex values"
)
)
# Contact type constants
CONTACT_TYPE_REPEATER = 2
CONTACT_TYPE_ROOM = 3
class ContactAdvertPath(BaseModel):
@@ -79,7 +240,8 @@ class ContactAdvertPath(BaseModel):
path: str = Field(description="Hex-encoded routing path (empty string for direct)")
path_len: int = Field(description="Number of hops in the path")
next_hop: str | None = Field(
default=None, description="First hop toward us (2-char hex), or null for direct"
default=None,
description="First hop toward us as a full hop identifier, or null for direct",
)
first_seen: int = Field(description="Unix timestamp of first observation")
last_seen: int = Field(description="Unix timestamp of most recent observation")
@@ -137,19 +299,101 @@ class ContactDetail(BaseModel):
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
class NameOnlyContactDetail(BaseModel):
"""Channel activity summary for a sender name that is not tied to a known key."""
name: str
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the hour bucket")
last_24h_count: int = 0
last_week_average: float = 0
all_time_average: float = 0
class ContactAnalyticsWeeklyBucket(BaseModel):
"""A single weekly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the 7-day bucket")
message_count: int = 0
class ContactAnalytics(BaseModel):
"""Unified contact analytics payload for keyed and name-only lookups."""
lookup_type: Literal["contact", "name"]
name: str
contact: Contact | None = None
name_first_seen_at: int | None = None
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
includes_direct_messages: bool = False
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
advert_frequency: float | None = Field(
default=None,
description="Advert observations per hour (includes multi-path arrivals of same advert)",
)
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
hourly_activity: list[ContactAnalyticsHourlyBucket] = Field(default_factory=list)
weekly_activity: list[ContactAnalyticsWeeklyBucket] = Field(default_factory=list)
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str
is_hashtag: bool = False
on_radio: bool = False
flood_scope_override: str | None = Field(
default=None,
description="Per-channel outbound flood scope override (null = use global app setting)",
)
last_read_at: int | None = None # Server-side read state tracking
class ChannelMessageCounts(BaseModel):
"""Time-windowed message counts for a channel."""
last_1h: int = 0
last_24h: int = 0
last_48h: int = 0
last_7d: int = 0
all_time: int = 0
class ChannelTopSender(BaseModel):
"""A top sender in a channel over the last 24 hours."""
sender_name: str
sender_key: str | None = None
message_count: int
class ChannelDetail(BaseModel):
"""Comprehensive channel profile data."""
channel: Channel
message_counts: ChannelMessageCounts = Field(default_factory=ChannelMessageCounts)
first_message_at: int | None = None
unique_sender_count: int = 0
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
class MessagePath(BaseModel):
"""A single path that a message took to reach us."""
path: str = Field(description="Hex-encoded routing path (2 chars per hop)")
path: str = Field(description="Hex-encoded routing path")
received_at: int = Field(description="Unix timestamp when this path was received")
path_len: int | None = Field(
default=None,
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
)
class Message(BaseModel):
@@ -164,8 +408,23 @@ class Message(BaseModel):
)
txt_type: int = 0
signature: str | None = None
sender_key: str | None = None
outgoing: bool = False
acked: int = 0
sender_name: str | None = None
channel_name: str | None = None
class MessagesAroundResponse(BaseModel):
messages: list[Message]
has_older: bool
has_newer: bool
class ResendChannelMessageResponse(BaseModel):
status: str
message_id: int
message: Message | None = None
class RawPacketDecryptedInfo(BaseModel):
@@ -225,6 +484,11 @@ class RepeaterLoginResponse(BaseModel):
"""Response from repeater login."""
status: str = Field(description="Login result status")
authenticated: bool = Field(description="Whether repeater authentication was confirmed")
message: str | None = Field(
default=None,
description="Optional warning or error message when authentication was not confirmed",
)
class RepeaterStatusResponse(BaseModel):
@@ -249,8 +513,17 @@ class RepeaterStatusResponse(BaseModel):
full_events: int = Field(description="Full event queue count")
class RepeaterNodeInfoResponse(BaseModel):
"""Identity/location info from a repeater (small CLI batch)."""
name: str | None = Field(default=None, description="Repeater name")
lat: str | None = Field(default=None, description="Latitude")
lon: str | None = Field(default=None, description="Longitude")
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
class RepeaterRadioSettingsResponse(BaseModel):
"""Radio settings from a repeater (batch CLI get commands)."""
"""Radio settings from a repeater (radio/config CLI batch)."""
firmware_version: str | None = Field(default=None, description="Firmware version string")
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
@@ -258,10 +531,6 @@ class RepeaterRadioSettingsResponse(BaseModel):
airtime_factor: str | None = Field(default=None, description="Airtime factor")
repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled")
flood_max: str | None = Field(default=None, description="Max flood hops")
name: str | None = Field(default=None, description="Repeater name")
lat: str | None = Field(default=None, description="Latitude")
lon: str | None = Field(default=None, description="Longitude")
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
class RepeaterAdvertIntervalsResponse(BaseModel):
@@ -340,6 +609,30 @@ class TraceResponse(BaseModel):
path_len: int = Field(description="Number of hops in the trace path")
class PathDiscoveryRoute(BaseModel):
"""One resolved route returned by contact path discovery."""
path: str = Field(description="Hex-encoded path bytes")
path_len: int = Field(description="Hop count for this route")
path_hash_mode: int = Field(
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
)
class PathDiscoveryResponse(BaseModel):
"""Round-trip routing data for a contact path discovery request."""
contact: Contact = Field(
description="Updated contact row after saving the learned forward path"
)
forward_path: PathDiscoveryRoute = Field(
description="Route used from the local radio to the target contact"
)
return_path: PathDiscoveryRoute = Field(
description="Route used from the target contact back to the local radio"
)
class CommandRequest(BaseModel):
"""Request to send a CLI command to a repeater."""
@@ -356,6 +649,48 @@ class CommandResponse(BaseModel):
)
class RadioDiscoveryRequest(BaseModel):
"""Request to discover nearby mesh nodes from the local radio."""
target: Literal["repeaters", "sensors", "all"] = Field(
default="all",
description="Which node classes to discover over the mesh",
)
class RadioDiscoveryResult(BaseModel):
"""One mesh node heard during a discovery sweep."""
public_key: str = Field(description="Discovered node public key as hex")
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
heard_count: int = Field(default=1, description="How many responses were heard from this node")
local_snr: float | None = Field(
default=None,
description="SNR at which the local radio heard the response (dB)",
)
local_rssi: int | None = Field(
default=None,
description="RSSI at which the local radio heard the response (dBm)",
)
remote_snr: float | None = Field(
default=None,
description="SNR reported by the remote node while hearing our discovery request (dB)",
)
class RadioDiscoveryResponse(BaseModel):
"""Response payload for a mesh discovery sweep."""
target: Literal["repeaters", "sensors", "all"] = Field(
description="Which node classes were requested"
)
duration_seconds: float = Field(description="How long the sweep listened for responses")
results: list[RadioDiscoveryResult] = Field(
default_factory=list,
description="Deduplicated discovery responses heard during the sweep",
)
class Favorite(BaseModel):
"""A favorite conversation."""
@@ -363,15 +698,6 @@ class Favorite(BaseModel):
id: str = Field(description="Channel key or contact public key")
class BotConfig(BaseModel):
"""Configuration for a single bot."""
id: str = Field(description="UUID for stable identity across renames/reorders")
name: str = Field(description="User-editable name")
enabled: bool = Field(default=False, description="Whether this bot is enabled")
code: str = Field(default="", description="Python code for this bot")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -384,6 +710,9 @@ class UnreadCounts(BaseModel):
last_message_times: dict[str, int] = Field(
default_factory=dict, description="Map of stateKey -> last message timestamp"
)
last_read_ats: dict[str, int | None] = Field(
default_factory=dict, description="Map of stateKey -> server-side last_read_at boundary"
)
class AppSettings(BaseModel):
@@ -392,8 +721,8 @@ class AppSettings(BaseModel):
max_radio_contacts: int = Field(
default=200,
description=(
"Maximum contacts to keep on radio for DM ACKs "
"(favorite contacts first, then recent non-repeaters)"
"Configured radio contact capacity used for maintenance thresholds; "
"favorites reload first, then background fill targets about 80% of this value"
),
)
favorites: list[Favorite] = Field(
@@ -423,48 +752,33 @@ class AppSettings(BaseModel):
default=0,
description="Unix timestamp of last advertisement sent (0 = never)",
)
bots: list[BotConfig] = Field(
flood_scope: str = Field(
default="",
description="Outbound flood scope / region name (empty = disabled, no tagging)",
)
blocked_keys: list[str] = Field(
default_factory=list,
description="List of bot configurations",
description="Public keys whose messages are hidden from the UI",
)
mqtt_broker_host: str = Field(
default="",
description="MQTT broker hostname (empty = disabled)",
)
mqtt_broker_port: int = Field(
default=1883,
description="MQTT broker port",
)
mqtt_username: str = Field(
default="",
description="MQTT username (optional)",
)
mqtt_password: str = Field(
default="",
description="MQTT password (optional)",
)
mqtt_use_tls: bool = Field(
default=False,
description="Whether to use TLS for MQTT connection",
)
mqtt_tls_insecure: bool = Field(
default=False,
description="Skip TLS certificate verification (for self-signed certs)",
)
mqtt_topic_prefix: str = Field(
default="meshcore",
description="MQTT topic prefix",
)
mqtt_publish_messages: bool = Field(
default=False,
description="Whether to publish decrypted messages to MQTT",
)
mqtt_publish_raw_packets: bool = Field(
default=False,
description="Whether to publish raw packets to MQTT",
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' | 'sqs'
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
@@ -477,6 +791,16 @@ class ContactActivityCounts(BaseModel):
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
@@ -490,3 +814,4 @@ class StatisticsResponse(BaseModel):
total_outgoing: int
contacts_heard: ContactActivityCounts
repeaters_heard: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats

View File

@@ -1,223 +0,0 @@
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
from __future__ import annotations
import asyncio
import json
import logging
import ssl
from typing import Any
import aiomqtt
from app.models import AppSettings
logger = logging.getLogger(__name__)
# Reconnect backoff: start at 5s, cap at 30s
_BACKOFF_MIN = 5
_BACKOFF_MAX = 30
class MqttPublisher:
"""Manages an MQTT connection and publishes mesh network events."""
def __init__(self) -> None:
self._client: aiomqtt.Client | None = None
self._task: asyncio.Task[None] | None = None
self._settings: AppSettings | None = None
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
async def start(self, settings: AppSettings) -> 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: AppSettings) -> None:
"""Called when MQTT settings change — stop + start."""
await self.stop()
await self.start(settings)
async def publish(self, topic: str, payload: dict[str, Any]) -> 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))
except Exception as e:
logger.warning("MQTT publish failed on %s: %s", topic, e)
self.connected = False
# Wake the connection loop so it exits the wait and reconnects
self._settings_version += 1
self._version_event.set()
def _mqtt_configured(self) -> bool:
"""Check if MQTT is configured (broker host is set)."""
return bool(self._settings and self._settings.mqtt_broker_host)
async def _connection_loop(self) -> None:
"""Background loop: connect, wait, reconnect on failure."""
from app.websocket import broadcast_error, broadcast_success
backoff = _BACKOFF_MIN
while True:
if not self._mqtt_configured():
self.connected = False
self._client = None
# Wait until settings change (which might configure MQTT)
self._version_event.clear()
try:
await self._version_event.wait()
except asyncio.CancelledError:
return
continue
settings = self._settings
assert settings is not None # guaranteed by _mqtt_configured()
version_at_connect = self._settings_version
try:
tls_context = self._build_tls_context(settings)
async with aiomqtt.Client(
hostname=settings.mqtt_broker_host,
port=settings.mqtt_broker_port,
username=settings.mqtt_username or None,
password=settings.mqtt_password or None,
tls_context=tls_context,
) as client:
self._client = client
self.connected = True
backoff = _BACKOFF_MIN
broadcast_success(
"MQTT connected",
f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}",
)
_broadcast_mqtt_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:
continue
except asyncio.CancelledError:
self.connected = False
self._client = None
return
except Exception as e:
self.connected = False
self._client = None
broadcast_error(
"MQTT connection failure",
"Please correct the settings or disable.",
)
_broadcast_mqtt_health()
logger.warning("MQTT connection error: %s (reconnecting in %ds)", e, backoff)
try:
await asyncio.sleep(backoff)
except asyncio.CancelledError:
return
backoff = min(backoff * 2, _BACKOFF_MAX)
@staticmethod
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
"""Build TLS context from settings, or None if TLS is disabled."""
if not settings.mqtt_use_tls:
return None
ctx = ssl.create_default_context()
if settings.mqtt_tls_insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# Module-level singleton
mqtt_publisher = MqttPublisher()
def _broadcast_mqtt_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)
def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
"""Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
if event_type not in ("message", "raw_packet"):
return
if not mqtt_publisher.connected or mqtt_publisher._settings is None:
return
asyncio.create_task(_mqtt_maybe_publish(event_type, data))
async def _mqtt_maybe_publish(event_type: str, data: dict[str, Any]) -> None:
"""Check settings and build topic, then publish."""
settings = mqtt_publisher._settings
if settings is None:
return
try:
if event_type == "message" and settings.mqtt_publish_messages:
topic = _build_message_topic(settings.mqtt_topic_prefix, data)
await mqtt_publisher.publish(topic, data)
elif event_type == "raw_packet" and settings.mqtt_publish_raw_packets:
topic = _build_raw_packet_topic(settings.mqtt_topic_prefix, data)
await mqtt_publisher.publish(topic, data)
except Exception as e:
logger.warning("MQTT broadcast error: %s", e)
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a decrypted message."""
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "unknown")
if msg_type == "PRIV":
return f"{prefix}/dm:{conversation_key}"
elif msg_type == "CHAN":
return f"{prefix}/gm:{conversation_key}"
return f"{prefix}/message:{conversation_key}"
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a raw packet."""
info = data.get("decrypted_info")
if info and isinstance(info, dict):
contact_key = info.get("contact_key")
channel_key = info.get("channel_key")
if contact_key:
return f"{prefix}/raw/dm:{contact_key}"
if channel_key:
return f"{prefix}/raw/gm:{channel_key}"
return f"{prefix}/raw/unrouted"

View File

@@ -26,23 +26,32 @@ from app.decoder import (
parse_packet,
try_decrypt_dm,
try_decrypt_packet_with_channel_key,
try_decrypt_path,
)
from app.keystore import get_private_key, get_public_key, has_private_key
from app.models import (
CONTACT_TYPE_REPEATER,
Message,
MessagePath,
Contact,
ContactUpsert,
RawPacketBroadcast,
RawPacketDecryptedInfo,
)
from app.repository import (
ChannelRepository,
ContactAdvertPathRepository,
ContactNameHistoryRepository,
ContactRepository,
MessageRepository,
RawPacketRepository,
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.messages import (
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
)
from app.services.messages import (
create_message_from_decrypted as _create_message_from_decrypted,
)
from app.websocket import broadcast_error, broadcast_event
logger = logging.getLogger(__name__)
@@ -50,76 +59,6 @@ logger = logging.getLogger(__name__)
_raw_observation_counter = count(1)
async def _handle_duplicate_message(
packet_id: int,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int,
path: str | None,
received: int,
) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record.
Called when MessageRepository.create returns None (INSERT OR IGNORE hit a duplicate).
Looks up the existing message, adds the new path, increments ack count for outgoing
messages, and broadcasts the update to clients.
"""
existing_msg = await MessageRepository.get_by_content(
msg_type=msg_type,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
)
if not existing_msg:
label = "message" if msg_type == "CHAN" else "DM"
logger.warning(
"Duplicate %s for %s but couldn't find existing",
label,
conversation_key[:12],
)
return
logger.debug(
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
msg_type,
conversation_key[:12],
existing_msg.id,
existing_msg.outgoing,
)
# Add path if provided
if path is not None:
paths = await MessageRepository.add_path(existing_msg.id, path, received)
else:
# Get current paths for broadcast
paths = existing_msg.paths or []
# Increment ack count for outgoing messages (echo confirmation)
if existing_msg.outgoing:
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
else:
ack_count = existing_msg.acked
# Only broadcast when something actually changed:
# - outgoing: ack count was incremented
# - path provided: a new path entry was appended
# The path=None case happens for direct-delivery DMs (0-hop, no routing bytes).
# A non-outgoing duplicate with no new path changes nothing in the DB, so skip.
if existing_msg.outgoing or path is not None:
broadcast_event(
"message_acked",
{
"message_id": existing_msg.id,
"ack_count": ack_count,
"paths": [p.model_dump() for p in paths] if paths else [],
},
)
# Mark this packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
async def create_message_from_decrypted(
packet_id: int,
channel_key: str,
@@ -128,107 +67,25 @@ async def create_message_from_decrypted(
timestamp: int,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
channel_name: str | None = None,
trigger_bot: bool = True,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted channel packet content.
This is the shared logic for storing decrypted channel messages,
used by both real-time packet processing and historical decryption.
Args:
packet_id: ID of the raw packet being processed
channel_key: Hex string channel key
channel_name: Channel name (e.g. "#general"), for bot context
sender: Sender name (will be prefixed to message) or None
message_text: The decrypted message content
timestamp: Sender timestamp from the packet
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
trigger_bot: Whether to trigger bot response (False for historical decryption)
Returns the message ID if created, None if duplicate.
"""
received = received_at or int(time.time())
# Format the message text with sender prefix if present
text = f"{sender}: {message_text}" if sender else message_text
# Normalize channel key to uppercase for consistency
channel_key_normalized = channel_key.upper()
# Resolve sender_key: look up contact by exact name match
resolved_sender_key: str | None = None
if sender:
candidates = await ContactRepository.get_by_name(sender)
if len(candidates) == 1:
resolved_sender_key = candidates[0].public_key
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=text,
conversation_key=channel_key_normalized,
sender_timestamp=timestamp,
received_at=received,
"""Store a decrypted channel message via the shared message service."""
return await _create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key,
sender=sender,
message_text=message_text,
timestamp=timestamp,
received_at=received_at,
path=path,
sender_name=sender,
sender_key=resolved_sender_key,
path_len=path_len,
channel_name=channel_name,
realtime=realtime,
broadcast_fn=broadcast_event,
)
if msg_id is None:
# Duplicate message detected - this happens when:
# 1. Our own outgoing message echoes back (flood routing)
# 2. Same message arrives via multiple paths before first is committed
# In either case, add the path to the existing message.
await _handle_duplicate_message(
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received
)
return None
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
# Build paths array for broadcast
# Use "is not None" to include empty string (direct/0-hop messages)
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
# Broadcast new message to connected clients
broadcast_event(
"message",
Message(
id=msg_id,
type="CHAN",
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
received_at=received,
paths=paths,
).model_dump(),
)
# Run bot if enabled (for incoming channel messages, not historical decryption)
if trigger_bot:
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=sender,
sender_key=None, # Channel messages don't have a sender public key
message_text=message_text,
is_dm=False,
channel_key=channel_key_normalized,
channel_name=channel_name,
sender_timestamp=timestamp,
path=path,
is_outgoing=False,
)
)
return msg_id
async def create_dm_message_from_decrypted(
packet_id: int,
@@ -237,122 +94,24 @@ async def create_dm_message_from_decrypted(
our_public_key: str | None,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
outgoing: bool = False,
trigger_bot: bool = True,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted direct message packet content.
This is the shared logic for storing decrypted direct messages,
used by both real-time packet processing and historical decryption.
Args:
packet_id: ID of the raw packet being processed
decrypted: DecryptedDirectMessage from decoder
their_public_key: The contact's full 64-char public key (conversation_key)
our_public_key: Our public key (to determine direction), or None
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
outgoing: Whether this is an outgoing message (we sent it)
trigger_bot: Whether to trigger bot response (False for historical decryption)
Returns the message ID if created, None if duplicate.
"""
# Check if sender is a repeater - repeaters only send CLI responses, not chat messages.
# CLI responses are handled by the command endpoint, not stored in chat history.
contact = await ContactRepository.get_by_key(their_public_key)
if contact and contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (CLI responses not stored): %s",
their_public_key[:12],
(decrypted.message or "")[:50],
)
return None
received = received_at or int(time.time())
# conversation_key is always the other party's public key
conversation_key = their_public_key.lower()
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=decrypted.message,
conversation_key=conversation_key,
sender_timestamp=decrypted.timestamp,
received_at=received,
"""Store a decrypted direct message via the shared message service."""
return await _create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=their_public_key,
our_public_key=our_public_key,
received_at=received_at,
path=path,
path_len=path_len,
outgoing=outgoing,
sender_key=conversation_key if not outgoing else None,
realtime=realtime,
broadcast_fn=broadcast_event,
)
if msg_id is None:
# Duplicate message detected
await _handle_duplicate_message(
packet_id,
"PRIV",
conversation_key,
decrypted.message,
decrypted.timestamp,
path,
received,
)
return None
logger.info(
"Stored direct message %d for contact %s (outgoing=%s)",
msg_id,
conversation_key[:12],
outgoing,
)
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
# Build paths array for broadcast
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
# Broadcast new message to connected clients
broadcast_event(
"message",
Message(
id=msg_id,
type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
paths=paths,
outgoing=outgoing,
).model_dump(),
)
# Update contact's last_contacted timestamp (for sorting)
await ContactRepository.update_last_contacted(conversation_key, received)
# Run bot if enabled (for all real-time DMs, including our own outgoing messages)
if trigger_bot:
from app.bot import run_bot_for_message
# Get contact name for the bot
contact = await ContactRepository.get_by_key(their_public_key)
sender_name = contact.name if contact else None
asyncio.create_task(
run_bot_for_message(
sender_name=sender_name,
sender_key=their_public_key,
message_text=decrypted.message,
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=decrypted.timestamp,
path=path,
is_outgoing=outgoing,
)
)
return msg_id
async def run_historical_dm_decryption(
private_key_bytes: bytes,
@@ -411,6 +170,7 @@ async def run_historical_dm_decryption(
# Extract path from the raw packet for storage
packet_info = parse_packet(packet_data)
path_hex = packet_info.path.hex() if packet_info else None
path_len = packet_info.path_length if packet_info else None
msg_id = await create_dm_message_from_decrypted(
packet_id=packet_id,
@@ -419,8 +179,9 @@ async def run_historical_dm_decryption(
our_public_key=our_public_key_bytes.hex(),
received_at=packet_timestamp,
path=path_hex,
path_len=path_len,
outgoing=outgoing,
trigger_bot=False, # Historical decryption should not trigger bot
realtime=False, # Historical decryption should not trigger fanout
)
if msg_id is not None:
@@ -518,6 +279,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(
@@ -552,8 +320,7 @@ async def process_raw_packet(
elif payload_type == PayloadType.ADVERT:
# Process all advert arrivals (even payload-hash duplicates) so the
# path-freshness logic in _process_advertisement can pick the shortest
# path heard within the freshness window.
# advert-history table retains recent path observations.
await _process_advertisement(raw_bytes, ts, packet_info)
elif payload_type == PayloadType.TEXT_MESSAGE:
@@ -562,6 +329,9 @@ async def process_raw_packet(
if decrypt_result:
result.update(decrypt_result)
elif payload_type == PayloadType.PATH:
await _process_path_packet(raw_bytes, ts, packet_info)
# Always broadcast raw packet for the packet feed UI (even duplicates)
# This enables the frontend cracker to see all incoming packets in real-time
broadcast_payload = RawPacketBroadcast(
@@ -627,6 +397,7 @@ async def _process_group_text(
timestamp=decrypted.timestamp,
received_at=timestamp,
path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None,
)
return {
@@ -650,7 +421,6 @@ async def _process_advertisement(
Process an advertisement packet.
Extracts contact info and updates the database/broadcasts to clients.
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
"""
# Parse packet to get path info if not already provided
if packet_info is None:
@@ -659,58 +429,30 @@ async def _process_advertisement(
logger.debug("Failed to parse advertisement packet")
return
advert = parse_advertisement(packet_info.payload)
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
if not advert:
logger.debug("Failed to parse advertisement payload")
return
# Extract path info from packet
new_path_len = packet_info.path_length
new_path_hex = packet_info.path.hex() if packet_info.path else ""
# Try to find existing contact
existing = await ContactRepository.get_by_key(advert.public_key.lower())
# Determine which path to use: keep shorter path if heard recently (within 60s)
# This handles advertisement echoes through different routes
PATH_FRESHNESS_SECONDS = 60
use_existing_path = False
if existing and existing.last_seen:
path_age = timestamp - existing.last_seen
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
# Keep existing path if it's fresh and shorter (or equal)
if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len:
use_existing_path = True
logger.debug(
"Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)",
advert.public_key[:12],
existing_path_len,
new_path_len,
path_age,
)
if use_existing_path:
assert existing is not None # Guaranteed by the conditions that set use_existing_path
path_len = existing.last_path_len if existing.last_path_len is not None else -1
path_hex = existing.last_path or ""
else:
path_len = new_path_len
path_hex = new_path_hex
logger.debug(
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)",
advert.public_key[:12],
advert.name,
advert.device_role,
advert.lat,
advert.lon,
path_len,
new_path_len,
)
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor)
# Use advert.timestamp for last_advert (sender's timestamp), receive timestamp for last_seen
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
# Persist advert freshness fields using the server receive wall clock so
# route selection is not affected by sender clock skew.
contact_type = (
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
@@ -721,45 +463,50 @@ async def _process_advertisement(
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_upsert = ContactUpsert(
public_key=advert.public_key.lower(),
name=advert.name,
type=contact_type,
lat=advert.lat,
lon=advert.lon,
last_advert=timestamp,
last_seen=timestamp,
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
contact_data = {
"public_key": advert.public_key.lower(),
"name": advert.name,
"type": contact_type,
"lat": advert.lat,
"lon": advert.lon,
"last_advert": advert.timestamp if advert.timestamp > 0 else timestamp,
"last_seen": timestamp,
"last_path": path_hex,
"last_path_len": path_len,
"first_seen": timestamp, # COALESCE in upsert preserves existing value
}
await ContactRepository.upsert(contact_data)
claimed = await MessageRepository.claim_prefix_messages(advert.public_key.lower())
if claimed > 0:
logger.info(
"Claimed %d prefix DM message(s) for contact %s",
claimed,
advert.public_key[:12],
)
await ContactRepository.upsert(contact_upsert)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key,
log=logger,
)
await record_contact_name_and_reconcile(
public_key=advert.public_key,
contact_name=advert.name,
timestamp=timestamp,
log=logger,
)
# Read back from DB so the broadcast includes all fields (last_contacted,
# last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly.
db_contact = await ContactRepository.get_by_key(advert.public_key.lower())
if db_contact:
broadcast_event("contact", db_contact.model_dump())
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": db_contact.model_dump(),
},
)
else:
broadcast_event("contact", contact_data)
broadcast_event(
"contact",
Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump(),
)
# For new contacts, optionally attempt to decrypt any historical DMs we may have stored
# This is controlled by the auto_decrypt_dm_on_advert setting
@@ -770,14 +517,6 @@ async def _process_advertisement(
if settings.auto_decrypt_dm_on_advert:
await start_historical_dm_decryption(None, advert.public_key.lower(), advert.name)
# If this is not a repeater, trigger recent contacts sync to radio
# This ensures we can auto-ACK DMs from recent contacts
if contact_type != CONTACT_TYPE_REPEATER:
# Import here to avoid circular import
from app.radio_sync import sync_recent_contacts_to_radio
asyncio.create_task(sync_recent_contacts_to_radio())
async def _process_direct_message(
raw_bytes: bytes,
@@ -882,7 +621,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,
)
@@ -897,3 +637,90 @@ async def _process_direct_message(
# Couldn't decrypt with any known contact
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
return None
async def _process_path_packet(
raw_bytes: bytes,
timestamp: int,
packet_info: PacketInfo | None,
) -> None:
"""Process a PATH packet and update the learned direct route."""
if not has_private_key():
return
private_key = get_private_key()
our_public_key = get_public_key()
if private_key is None or our_public_key is None:
return
if packet_info is None:
packet_info = parse_packet(raw_bytes)
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
return
dest_hash = format(packet_info.payload[0], "02x").lower()
src_hash = format(packet_info.payload[1], "02x").lower()
our_first_byte = format(our_public_key[0], "02x").lower()
if dest_hash != our_first_byte:
return
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
if not candidate_contacts:
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
return
for contact in candidate_contacts:
if len(contact.public_key) != 64:
continue
try:
contact_public_key = bytes.fromhex(contact.public_key)
except ValueError:
continue
result = try_decrypt_path(
raw_packet=raw_bytes,
our_private_key=private_key,
their_public_key=contact_public_key,
our_public_key=our_public_key,
)
if result is None:
continue
await ContactRepository.update_direct_path(
contact.public_key,
result.returned_path.hex(),
result.returned_path_len,
result.returned_path_hash_mode,
updated_at=timestamp,
)
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
ack_code = result.extra[:4].hex()
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info(
"Applied bundled PATH ACK for %s via contact %s",
ack_code,
contact.public_key[:12],
)
else:
logger.debug(
"Buffered bundled PATH ACK %s via contact %s",
ack_code,
contact.public_key[:12],
)
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
logger.debug(
"Observed bundled PATH RESPONSE from %s (%d bytes)",
contact.public_key[:12],
len(result.extra),
)
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
if refreshed_contact is not None:
broadcast_event("contact", refreshed_contact.model_dump())
return
logger.debug(
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
)

246
app/path_utils.py Normal file
View File

@@ -0,0 +1,246 @@
"""
Centralized helpers for MeshCore multi-byte path encoding.
The path_len wire byte is packed as [hash_mode:2][hop_count:6]:
- hash_size = (hash_mode) + 1 → 1, 2, or 3 bytes per hop
- hop_count = lower 6 bits → 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
def normalize_contact_route(
path_hex: str | None,
path_len: int | None,
path_hash_mode: int | None,
) -> tuple[str, int, int]:
"""Normalize stored contact route fields.
Handles legacy/bad rows where the packed wire path byte was stored directly
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
Returns `(path_hex, hop_count, hash_mode)`.
"""
normalized_path = path_hex or ""
try:
normalized_len = int(path_len) if path_len is not None else -1
except (TypeError, ValueError):
normalized_len = -1
try:
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
except (TypeError, ValueError):
normalized_mode = None
if normalized_len < -1 or normalized_len > 63:
packed = normalized_len & 0xFF
if packed == 0xFF:
return "", -1, -1
decoded_mode = (packed >> 6) & 0x03
if decoded_mode != 0x03:
normalized_len = packed & 0x3F
normalized_mode = decoded_mode
if normalized_len == -1:
return "", -1, -1
if normalized_mode not in (0, 1, 2):
normalized_mode = 0
if normalized_path:
bytes_per_hop = normalized_mode + 1
actual_bytes = len(normalized_path) // 2
expected_bytes = normalized_len * bytes_per_hop
if actual_bytes > expected_bytes >= 0:
normalized_path = normalized_path[: expected_bytes * 2]
elif (
actual_bytes < expected_bytes
and bytes_per_hop > 0
and actual_bytes % bytes_per_hop == 0
):
normalized_len = actual_bytes // bytes_per_hop
return normalized_path, normalized_len, normalized_mode
def normalize_route_override(
path_hex: str | None,
path_len: int | None,
path_hash_mode: int | None,
) -> tuple[str | None, int | None, int | None]:
"""Normalize optional route-override fields while preserving the unset state."""
if path_len is None:
return None, None, None
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
path_hex,
path_len,
path_hash_mode,
)
return normalized_path, normalized_len, normalized_mode
def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
"""Parse a comma-separated explicit hop route into stored contact fields."""
hops = [hop.strip().lower() for hop in route_text.split(",") if hop.strip()]
if not hops:
raise ValueError("Explicit path must include at least one hop")
hop_chars = len(hops[0])
if hop_chars not in (2, 4, 6):
raise ValueError("Each hop must be 1, 2, or 3 bytes of hex")
for hop in hops:
if len(hop) != hop_chars:
raise ValueError("All hops must use the same width")
try:
bytes.fromhex(hop)
except ValueError as exc:
raise ValueError("Each hop must be valid hex") from exc
hash_size = hop_chars // 2
if path_wire_len(len(hops), hash_size) > MAX_PATH_SIZE:
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
return "".join(hops), len(hops), hash_size - 1

View File

@@ -2,14 +2,20 @@ import asyncio
import glob
import logging
import platform
import re
from collections import OrderedDict
from contextlib import asynccontextmanager, nullcontext
from pathlib import Path
from meshcore import MeshCore
from serial.serialutil import SerialException
from app.config import settings
from app.keystore import clear_keys
logger = logging.getLogger(__name__)
MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
_SERIAL_PORT_ERROR_RE = re.compile(r"could not open port (?P<port>.+?):")
class RadioOperationError(RuntimeError):
@@ -66,6 +72,36 @@ def detect_serial_devices() -> list[str]:
return devices
def _extract_serial_port_from_error(exc: Exception) -> str | None:
"""Best-effort extraction of a serial port path from a pyserial error."""
message = str(exc)
match = _SERIAL_PORT_ERROR_RE.search(message)
if match:
return match.group("port")
return None
def _format_reconnect_failure(exc: Exception) -> tuple[str, str, bool]:
"""Return log message, frontend detail, and whether to log a traceback."""
if settings.connection_type == "serial":
if isinstance(exc, RuntimeError) and str(exc).startswith("No MeshCore radio found"):
message = (
"Could not find a MeshCore radio on any serial port. "
"Did the radio get disconnected or change serial ports?"
)
return (message, message, False)
if isinstance(exc, SerialException):
port = settings.serial_port or _extract_serial_port_from_error(exc) or "the serial port"
message = (
f"Could not connect to serial port {port}. "
"Did the radio get disconnected or change serial ports?"
)
return (message, message, False)
return (f"Reconnection failed: {exc}", str(exc), True)
async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) -> bool:
"""Test if a MeshCore radio responds on the given serial port."""
mc = None
@@ -121,6 +157,7 @@ class RadioManager:
def __init__(self):
self._meshcore: MeshCore | None = None
self._connection_info: str | None = None
self._connection_desired: bool = True
self._reconnect_task: asyncio.Task | None = None
self._last_connected: bool = False
self._reconnect_lock: asyncio.Lock | None = None
@@ -128,7 +165,18 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self._loopback_active: bool = False
self._frontend_reconnect_error_broadcasts: int = 0
self.device_info_loaded: bool = False
self.max_contacts: int | None = None
self.device_model: str | None = None
self.firmware_build: str | None = None
self.firmware_version: str | None = None
self.max_channels: int = 40
self.path_hash_mode: int = 0
self.path_hash_mode_supported: bool = False
self._channel_slot_by_key: OrderedDict[str, int] = OrderedDict()
self._channel_key_by_slot: dict[int, str] = {}
self._pending_message_channel_key_by_slot: dict[int, str] = {}
async def _acquire_operation_lock(
self,
@@ -158,6 +206,20 @@ class RadioManager:
else:
logger.error("Attempted to release unlocked radio operation lock (%s)", name)
def _reset_connected_runtime_state(self) -> None:
"""Clear cached runtime state after a transport teardown completes."""
self._setup_complete = False
self.device_info_loaded = False
self.max_contacts = None
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
self.reset_channel_send_cache()
self.clear_pending_message_channel_slots()
@asynccontextmanager
async def radio_operation(
self,
@@ -216,83 +278,125 @@ class RadioManager:
self._release_operation_lock(name)
async def post_connect_setup(self) -> None:
"""Full post-connection setup: handlers, key export, sync, advertisements, polling.
"""Run shared post-connection orchestration after transport setup succeeds."""
from app.services.radio_lifecycle import run_post_connect_setup
Called after every successful connection or reconnection.
Idempotent — safe to call repeatedly (periodic tasks have start guards).
await run_post_connect_setup(self)
def reset_channel_send_cache(self) -> None:
"""Forget any session-local channel-slot reuse state."""
self._channel_slot_by_key.clear()
self._channel_key_by_slot.clear()
def remember_pending_message_channel_slot(self, channel_key: str, slot: int) -> None:
"""Remember a channel key for later queued-message recovery."""
self._pending_message_channel_key_by_slot[slot] = channel_key.upper()
def get_pending_message_channel_key(self, slot: int) -> str | None:
"""Return the last remembered channel key for a radio slot."""
return self._pending_message_channel_key_by_slot.get(slot)
def clear_pending_message_channel_slots(self) -> None:
"""Drop any queued-message recovery slot metadata."""
self._pending_message_channel_key_by_slot.clear()
def channel_slot_reuse_enabled(self) -> bool:
"""Return whether this transport can safely reuse cached channel slots."""
if settings.force_channel_slot_reconfigure:
return False
if self._connection_info:
return not self._connection_info.startswith("TCP:")
return settings.connection_type != "tcp"
def get_channel_send_cache_capacity(self) -> int:
"""Return the app-managed channel cache capacity for the current session."""
try:
return max(1, int(self.max_channels))
except (TypeError, ValueError):
return 1
def get_cached_channel_slot(self, channel_key: str) -> int | None:
"""Return the cached radio slot for a channel key, if present."""
return self._channel_slot_by_key.get(channel_key.upper())
def plan_channel_send_slot(
self,
channel_key: str,
*,
preferred_slot: int = 0,
) -> tuple[int, bool, str | None]:
"""Choose a radio slot for a channel send.
Returns `(slot, needs_configure, evicted_channel_key)`.
"""
from app.event_handlers import register_event_handlers
from app.keystore import export_and_store_private_key
from app.radio_sync import (
drain_pending_messages,
send_advertisement,
start_message_polling,
start_periodic_advert,
start_periodic_sync,
sync_and_offload_all,
sync_radio_time,
)
if not self.channel_slot_reuse_enabled():
return preferred_slot, True, None
if not self._meshcore:
normalized_key = channel_key.upper()
cached_slot = self._channel_slot_by_key.get(normalized_key)
if cached_slot is not None:
return cached_slot, False, None
capacity = self.get_channel_send_cache_capacity()
if len(self._channel_slot_by_key) < capacity:
slot = self._find_first_free_channel_slot(capacity, preferred_slot)
return slot, True, None
evicted_key, slot = next(iter(self._channel_slot_by_key.items()))
return slot, True, evicted_key
def note_channel_slot_loaded(self, channel_key: str, slot: int) -> None:
"""Record that a channel is now resident in the given radio slot."""
if not self.channel_slot_reuse_enabled():
return
if self._setup_lock is None:
self._setup_lock = asyncio.Lock()
normalized_key = channel_key.upper()
previous_slot = self._channel_slot_by_key.pop(normalized_key, None)
if previous_slot is not None and previous_slot != slot:
self._channel_key_by_slot.pop(previous_slot, None)
async with self._setup_lock:
if not self._meshcore:
return
self._setup_in_progress = True
self._setup_complete = False
mc = self._meshcore
try:
# Register event handlers (no radio I/O, just callback setup)
register_event_handlers(mc)
displaced_key = self._channel_key_by_slot.get(slot)
if displaced_key is not None and displaced_key != normalized_key:
self._channel_slot_by_key.pop(displaced_key, None)
# Hold the operation lock for all radio I/O during setup.
# This prevents user-initiated operations (send message, etc.)
# from interleaving commands on the serial link.
await self._acquire_operation_lock("post_connect_setup", blocking=True)
try:
await export_and_store_private_key(mc)
self._channel_key_by_slot[slot] = normalized_key
self._channel_slot_by_key[normalized_key] = slot
# Sync radio clock with system time
await sync_radio_time(mc)
def note_channel_slot_used(self, channel_key: str) -> None:
"""Refresh LRU order for a previously loaded channel slot."""
if not self.channel_slot_reuse_enabled():
return
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
normalized_key = channel_key.upper()
slot = self._channel_slot_by_key.get(normalized_key)
if slot is None:
return
self._channel_slot_by_key.move_to_end(normalized_key)
self._channel_key_by_slot[slot] = normalized_key
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
logger.debug("Advertisement skipped (disabled or throttled)")
def invalidate_cached_channel_slot(self, channel_key: str) -> None:
"""Drop any cached slot assignment for a channel key."""
normalized_key = channel_key.upper()
slot = self._channel_slot_by_key.pop(normalized_key, None)
if slot is None:
return
if self._channel_key_by_slot.get(slot) == normalized_key:
self._channel_key_by_slot.pop(slot, None)
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
def get_channel_send_cache_snapshot(self) -> list[tuple[str, int]]:
"""Return the current channel send cache contents in LRU order."""
return list(self._channel_slot_by_key.items())
await mc.start_auto_message_fetching()
logger.info("Auto message fetching started")
finally:
self._release_operation_lock("post_connect_setup")
def _find_first_free_channel_slot(self, capacity: int, preferred_slot: int) -> int:
"""Pick the first unclaimed app-managed slot, preferring the requested slot."""
if preferred_slot < capacity and preferred_slot not in self._channel_key_by_slot:
return preferred_slot
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
for slot in range(capacity):
if slot not in self._channel_key_by_slot:
return slot
self._setup_complete = True
finally:
self._setup_in_progress = False
logger.info("Post-connect setup complete")
return preferred_slot
@property
def meshcore(self) -> MeshCore | None:
@@ -319,34 +423,54 @@ class RadioManager:
return self._setup_complete
@property
def loopback_active(self) -> bool:
return self._loopback_active
def connection_desired(self) -> bool:
return self._connection_desired
def connect_loopback(self, mc: MeshCore, connection_info: str) -> None:
"""Adopt a MeshCore instance created by the loopback WebSocket endpoint."""
self._meshcore = mc
self._connection_info = connection_info
self._loopback_active = True
self._last_connected = True
self._setup_complete = False
def resume_connection(self) -> None:
"""Allow connection monitor and manual reconnects to establish transport again."""
self._connection_desired = True
async def disconnect_loopback(self) -> None:
"""Tear down a loopback session and resume normal auto-detect."""
from app.websocket import broadcast_health
async def pause_connection(self) -> None:
"""Stop automatic reconnect attempts and tear down any current transport."""
self._connection_desired = False
self._last_connected = False
await self.disconnect()
mc = self._meshcore
self._meshcore = None
self._loopback_active = False
self._connection_info = None
self._setup_complete = False
def _reset_reconnect_error_broadcasts(self) -> None:
self._frontend_reconnect_error_broadcasts = 0
if mc is not None:
try:
await mc.disconnect()
except Exception:
pass
def _broadcast_reconnect_error_if_needed(self, details: str) -> None:
from app.websocket import broadcast_error
broadcast_health(False, None)
self._frontend_reconnect_error_broadcasts += 1
if self._frontend_reconnect_error_broadcasts > MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
return
if self._frontend_reconnect_error_broadcasts == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
details = f"{details} Further reconnect failures will be logged only until a connection succeeds."
broadcast_error("Reconnection failed", details)
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
"""Disable library-managed reconnects so manual teardown fully releases transport."""
connection_manager = getattr(mc, "connection_manager", None)
if connection_manager is None:
return
if hasattr(connection_manager, "auto_reconnect"):
connection_manager.auto_reconnect = False
reconnect_task = getattr(connection_manager, "_reconnect_task", None)
if reconnect_task is None or not isinstance(reconnect_task, asyncio.Task | asyncio.Future):
return
reconnect_task.cancel()
try:
await reconnect_task
except asyncio.CancelledError:
pass
finally:
connection_manager._reconnect_task = None
async def connect(self) -> None:
"""Connect to the radio using the configured transport."""
@@ -424,12 +548,30 @@ class RadioManager:
async def disconnect(self) -> None:
"""Disconnect from the radio."""
if self._meshcore is not None:
clear_keys()
self._reset_reconnect_error_broadcasts()
if self._meshcore is None:
return
await self._acquire_operation_lock("disconnect", blocking=True)
try:
mc = self._meshcore
if mc is None:
return
logger.debug("Disconnecting from radio")
await self._meshcore.disconnect()
self._meshcore = None
self._setup_complete = False
await self._disable_meshcore_auto_reconnect(mc)
try:
await mc.disconnect()
finally:
await self._disable_meshcore_auto_reconnect(mc)
if self._meshcore is mc:
self._meshcore = None
self._reset_connected_runtime_state()
logger.debug("Radio disconnected")
finally:
self._release_operation_lock("disconnect")
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
"""Attempt to reconnect to the radio.
@@ -437,13 +579,17 @@ class RadioManager:
Returns True if reconnection was successful, False otherwise.
Uses a lock to prevent concurrent reconnection attempts.
"""
from app.websocket import broadcast_error, broadcast_health
from app.websocket import broadcast_health
# Lazily initialize lock (can't create in __init__ before event loop exists)
if self._reconnect_lock is None:
self._reconnect_lock = asyncio.Lock()
async with self._reconnect_lock:
if not self._connection_desired:
logger.info("Reconnect skipped because connection is paused by operator")
return False
# If we became connected while waiting for the lock (another
# reconnect succeeded ahead of us), skip the redundant attempt.
if self.is_connected:
@@ -456,16 +602,21 @@ class RadioManager:
# Disconnect if we have a stale connection
if self._meshcore is not None:
try:
await self._meshcore.disconnect()
await self.disconnect()
except Exception:
pass
self._meshcore = None
# Try to connect (will auto-detect if no port specified)
await self.connect()
if not self._connection_desired:
logger.info("Reconnect completed after pause request; disconnecting transport")
await self.disconnect()
return False
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info)
self._reset_reconnect_error_broadcasts()
if broadcast_on_success:
broadcast_health(True, self._connection_info)
return True
@@ -474,68 +625,19 @@ class RadioManager:
return False
except Exception as e:
logger.warning("Reconnection failed: %s", e)
broadcast_error("Reconnection failed", str(e))
log_message, frontend_detail, include_traceback = _format_reconnect_failure(e)
logger.warning(log_message, exc_info=include_traceback)
self._broadcast_reconnect_error_if_needed(frontend_detail)
return False
async def start_connection_monitor(self) -> None:
"""Start background task to monitor connection and auto-reconnect."""
from app.services.radio_lifecycle import connection_monitor_loop
if self._reconnect_task is not None:
return
async def monitor_loop():
from app.websocket import broadcast_health
CHECK_INTERVAL_SECONDS = 5
while True:
try:
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
# Skip auto-detect/reconnect while loopback is active
if self._loopback_active:
continue
current_connected = self.is_connected
# Detect status change
if self._last_connected and not current_connected:
# Connection lost
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, self._connection_info)
self._last_connected = False
if not current_connected:
# Attempt reconnection on every loop while disconnected
if not self.is_reconnecting and await self.reconnect(
broadcast_on_success=False
):
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
elif not self._last_connected and current_connected:
# Connection restored (might have reconnected automatically).
# Always run setup before reporting healthy.
logger.info("Radio connection restored")
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
elif current_connected and not self._setup_complete:
# Transport connected but setup incomplete — retry
logger.info("Retrying post-connect setup...")
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
except asyncio.CancelledError:
# Task is being cancelled, exit cleanly
break
except Exception as e:
# Log error but continue monitoring - don't let the monitor die
logger.exception("Error in connection monitor, continuing: %s", e)
self._reconnect_task = asyncio.create_task(monitor_loop())
self._reconnect_task = asyncio.create_task(connection_monitor_loop(self))
logger.info("Radio connection monitor started")
async def stop_connection_monitor(self) -> None:

File diff suppressed because it is too large Load Diff

20
app/region_scope.py Normal file
View File

@@ -0,0 +1,20 @@
"""Helpers for normalizing MeshCore flood-scope / region names."""
def normalize_region_scope(scope: str | None) -> str:
"""Normalize a user-facing region scope into MeshCore's internal form.
Region names are now user-facing plain strings like ``Esperance``.
Internally, MeshCore still expects hashtag-style names like ``#Esperance``.
Backward compatibility:
- blank/None stays disabled (`""`)
- existing leading ``#`` is preserved
"""
stripped = (scope or "").strip()
if not stripped:
return ""
if stripped.startswith("#"):
return stripped
return f"#{stripped}"

View File

@@ -5,6 +5,7 @@ from app.repository.contacts import (
ContactNameHistoryRepository,
ContactRepository,
)
from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
@@ -16,6 +17,7 @@ __all__ = [
"ContactAdvertPathRepository",
"ContactNameHistoryRepository",
"ContactRepository",
"FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"StatisticsRepository",

View File

@@ -10,8 +10,8 @@ class ChannelRepository:
"""Upsert a channel. Key is 32-char hex string."""
await db.conn.execute(
"""
INSERT INTO channels (key, name, is_hashtag, on_radio)
VALUES (?, ?, ?, ?)
INSERT INTO channels (key, name, is_hashtag, on_radio, flood_scope_override)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(key) DO UPDATE SET
name = excluded.name,
is_hashtag = excluded.is_hashtag,
@@ -25,7 +25,11 @@ class ChannelRepository:
async def get_by_key(key: str) -> Channel | None:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels WHERE key = ?",
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels
WHERE key = ?
""",
(key.upper(),),
)
row = await cursor.fetchone()
@@ -35,6 +39,7 @@ class ChannelRepository:
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
last_read_at=row["last_read_at"],
)
return None
@@ -42,7 +47,11 @@ class ChannelRepository:
@staticmethod
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels ORDER BY name"
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels
ORDER BY name
"""
)
rows = await cursor.fetchall()
return [
@@ -51,6 +60,31 @@ class ChannelRepository:
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
last_read_at=row["last_read_at"],
)
for row in rows
]
@staticmethod
async def get_on_radio() -> list[Channel]:
"""Return channels currently marked as resident on the radio in the database."""
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels
WHERE on_radio = 1
ORDER BY name
"""
)
rows = await cursor.fetchall()
return [
Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
last_read_at=row["last_read_at"],
)
for row in rows
@@ -79,6 +113,16 @@ class ChannelRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def update_flood_scope_override(key: str, flood_scope_override: str | None) -> bool:
"""Set or clear a channel's flood-scope override."""
cursor = await db.conn.execute(
"UPDATE channels SET flood_scope_override = ? WHERE key = ?",
(flood_scope_override, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all channels as read at the given timestamp."""

View File

@@ -1,4 +1,5 @@
import time
from collections.abc import Mapping
from typing import Any
from app.database import db
@@ -7,7 +8,9 @@ from app.models import (
ContactAdvertPath,
ContactAdvertPathSummary,
ContactNameHistory,
ContactUpsert,
)
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
class AmbiguousPublicKeyPrefixError(ValueError):
@@ -21,19 +24,70 @@ class AmbiguousPublicKeyPrefixError(ValueError):
class ContactRepository:
@staticmethod
async def upsert(contact: dict[str, Any]) -> None:
def _coerce_contact_upsert(
contact: ContactUpsert | Contact | Mapping[str, Any],
) -> ContactUpsert:
if isinstance(contact, ContactUpsert):
return contact
if isinstance(contact, Contact):
return contact.to_upsert()
return ContactUpsert.model_validate(contact)
@staticmethod
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
contact_row = ContactRepository._coerce_contact_upsert(contact)
if (
contact_row.direct_path is None
and contact_row.direct_path_len is None
and contact_row.direct_path_hash_mode is None
):
direct_path = None
direct_path_len = None
direct_path_hash_mode = None
else:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
contact_row.direct_path,
contact_row.direct_path_len,
contact_row.direct_path_hash_mode,
)
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
contact_row.route_override_path,
contact_row.route_override_len,
contact_row.route_override_hash_mode,
)
)
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
last_advert, lat, lon, last_seen, on_radio, last_contacted,
first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
direct_path_hash_mode, direct_path_updated_at,
route_override_path, route_override_len,
route_override_hash_mode,
last_advert, lat, lon, last_seen,
on_radio, last_contacted, first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
direct_path_hash_mode = COALESCE(
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
),
direct_path_updated_at = COALESCE(
excluded.direct_path_updated_at, contacts.direct_path_updated_at
),
route_override_path = COALESCE(
excluded.route_override_path, contacts.route_override_path
),
route_override_len = COALESCE(
excluded.route_override_len, contacts.route_override_len
),
route_override_hash_mode = COALESCE(
excluded.route_override_hash_mode, contacts.route_override_hash_mode
),
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
lat = COALESCE(excluded.lat, contacts.lat),
lon = COALESCE(excluded.lon, contacts.lon),
@@ -43,19 +97,24 @@ class ContactRepository:
first_seen = COALESCE(contacts.first_seen, excluded.first_seen)
""",
(
contact.get("public_key", "").lower(),
contact.get("name"),
contact.get("type", 0),
contact.get("flags", 0),
contact.get("last_path"),
contact.get("last_path_len", -1),
contact.get("last_advert"),
contact.get("lat"),
contact.get("lon"),
contact.get("last_seen", int(time.time())),
contact.get("on_radio"),
contact.get("last_contacted"),
contact.get("first_seen"),
contact_row.public_key.lower(),
contact_row.name,
contact_row.type,
contact_row.flags,
direct_path,
direct_path_len,
direct_path_hash_mode,
contact_row.direct_path_updated_at,
route_override_path,
route_override_len,
route_override_hash_mode,
contact_row.last_advert,
contact_row.lat,
contact_row.lon,
contact_row.last_seen if contact_row.last_seen is not None else int(time.time()),
contact_row.on_radio,
contact_row.last_contacted,
contact_row.first_seen,
),
)
await db.conn.commit()
@@ -63,13 +122,46 @@ class ContactRepository:
@staticmethod
def _row_to_contact(row) -> Contact:
"""Convert a database row to a Contact model."""
available_columns = set(row.keys())
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
row["direct_path"] if "direct_path" in available_columns else None,
row["direct_path_len"] if "direct_path_len" in available_columns else None,
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
)
route_override_path = (
row["route_override_path"] if "route_override_path" in available_columns else None
)
route_override_len = (
row["route_override_len"] if "route_override_len" in available_columns else None
)
route_override_hash_mode = (
row["route_override_hash_mode"]
if "route_override_hash_mode" in available_columns
else None
)
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
route_override_path,
route_override_len,
route_override_hash_mode,
)
)
return Contact(
public_key=row["public_key"],
name=row["name"],
type=row["type"],
flags=row["flags"],
last_path=row["last_path"],
last_path_len=row["last_path_len"],
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
direct_path_updated_at=(
row["direct_path_updated_at"]
if "direct_path_updated_at" in available_columns
else None
),
route_override_path=route_override_path,
route_override_len=route_override_len,
route_override_hash_mode=route_override_hash_mode,
last_advert=row["last_advert"],
lat=row["lat"],
lon=row["lon"],
@@ -96,6 +188,9 @@ class ContactRepository:
the prefix (to avoid silently selecting the wrong contact).
"""
normalized_prefix = prefix.lower()
exact = await ContactRepository.get_by_key(normalized_prefix)
if exact:
return exact
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
(f"{normalized_prefix}%",),
@@ -181,17 +276,13 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get the most recently active non-repeater contacts.
Orders by most recent activity (last_contacted or last_advert),
excluding repeaters (type=2).
"""
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently interacted-with non-repeater contacts."""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64
ORDER BY last_contacted DESC
LIMIT ?
""",
(limit,),
@@ -200,19 +291,102 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(public_key: str, path: str, path_len: int) -> None:
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently advert-heard non-repeater contacts."""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64
ORDER BY last_advert DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_direct_path(
public_key: str,
path: str,
path_len: int,
path_hash_mode: int | None = None,
updated_at: int | None = None,
) -> None:
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
path,
path_len,
path_hash_mode,
)
ts = updated_at if updated_at is not None else int(time.time())
await db.conn.execute(
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
(path, path_len, int(time.time()), public_key.lower()),
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
direct_path_updated_at = ?,
last_seen = ? WHERE public_key = ?""",
(
normalized_path,
normalized_path_len,
normalized_hash_mode,
ts,
ts,
public_key.lower(),
),
)
await db.conn.commit()
@staticmethod
async def set_on_radio(public_key: str, on_radio: bool) -> None:
await db.conn.execute(
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
(on_radio, public_key.lower()),
async def set_routing_override(
public_key: str,
path: str | None,
path_len: int | None,
path_hash_mode: int | None = None,
) -> None:
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
path,
path_len,
path_hash_mode,
)
await db.conn.execute(
"""
UPDATE contacts
SET route_override_path = ?, route_override_len = ?, route_override_hash_mode = ?
WHERE public_key = ?
""",
(
normalized_path,
normalized_len,
normalized_hash_mode,
public_key.lower(),
),
)
await db.conn.commit()
@staticmethod
async def clear_routing_override(public_key: str) -> None:
await db.conn.execute(
"""
UPDATE contacts
SET route_override_path = NULL,
route_override_len = NULL,
route_override_hash_mode = NULL
WHERE public_key = ?
""",
(public_key.lower(),),
)
await db.conn.commit()
@staticmethod
async def clear_on_radio_except(keep_keys: list[str]) -> None:
"""Set on_radio=False for all contacts NOT in keep_keys."""
if not keep_keys:
await db.conn.execute("UPDATE contacts SET on_radio = 0 WHERE on_radio = 1")
else:
placeholders = ",".join("?" * len(keep_keys))
await db.conn.execute(
f"UPDATE contacts SET on_radio = 0 WHERE on_radio = 1 AND public_key NOT IN ({placeholders})",
keep_keys,
)
await db.conn.commit()
@staticmethod
@@ -251,6 +425,103 @@ class ContactRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def promote_prefix_placeholders(full_key: str) -> list[str]:
"""Promote prefix-only placeholder contacts to a resolved full key.
Returns the placeholder public keys that were merged into the full key.
"""
normalized_full_key = full_key.lower()
cursor = await db.conn.execute(
"""
SELECT public_key, last_seen, last_contacted, first_seen, last_read_at
FROM contacts
WHERE length(public_key) < 64
AND ? LIKE public_key || '%'
ORDER BY length(public_key) DESC, public_key
""",
(normalized_full_key,),
)
rows = list(await cursor.fetchall())
if not rows:
return []
promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows:
old_key = row["public_key"]
if old_key == normalized_full_key:
continue
match_cursor = await db.conn.execute(
"""
SELECT COUNT(*) AS match_count
FROM contacts
WHERE length(public_key) = 64
AND public_key LIKE ? || '%'
""",
(old_key,),
)
match_row = await match_cursor.fetchone()
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
if full_exists:
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = COALESCE(contacts.last_read_at, ?)
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
else:
await db.conn.execute(
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
(normalized_full_key, old_key),
)
full_exists = True
promoted_keys.append(old_key)
await db.conn.commit()
return promoted_keys
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all contacts as read at the given timestamp."""
@@ -274,10 +545,11 @@ class ContactAdvertPathRepository:
@staticmethod
def _row_to_path(row) -> ContactAdvertPath:
path = row["path_hex"] or ""
next_hop = path[:2].lower() if len(path) >= 2 else None
path_len = row["path_len"]
next_hop = first_hop_hex(path, path_len)
return ContactAdvertPath(
path=path,
path_len=row["path_len"],
path_len=path_len,
next_hop=next_hop,
first_seen=row["first_seen"],
last_seen=row["last_seen"],
@@ -290,6 +562,7 @@ class ContactAdvertPathRepository:
path_hex: str,
timestamp: int,
max_paths: int = 10,
hop_count: int | None = None,
) -> None:
"""
Upsert a unique advert path observation for a contact and prune to N most recent.
@@ -299,16 +572,15 @@ class ContactAdvertPathRepository:
normalized_key = public_key.lower()
normalized_path = path_hex.lower()
path_len = len(normalized_path) // 2
path_len = hop_count if hop_count is not None else len(normalized_path) // 2
await db.conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(public_key, path_hex) DO UPDATE SET
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
path_len = excluded.path_len,
heard_count = contact_advert_paths.heard_count + 1
""",
(normalized_key, normalized_path, path_len, timestamp, timestamp),
@@ -319,8 +591,8 @@ class ContactAdvertPathRepository:
"""
DELETE FROM contact_advert_paths
WHERE public_key = ?
AND path_hex NOT IN (
SELECT path_hex
AND id NOT IN (
SELECT id
FROM contact_advert_paths
WHERE public_key = ?
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC

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]

View File

@@ -1,12 +1,43 @@
import json
import re
import time
from dataclasses import dataclass
from typing import Any
from app.database import db
from app.models import Message, MessagePath
from app.models import (
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Message,
MessagePath,
)
class MessageRepository:
@dataclass
class _SearchQuery:
free_text: str
user_terms: list[str]
channel_terms: list[str]
_SEARCH_OPERATOR_RE = re.compile(
r'(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))',
re.IGNORECASE,
)
@staticmethod
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
lower_key = public_key.lower()
return (
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
[lower_key, lower_key],
)
@staticmethod
def _name_activity_filter(sender_name: str) -> tuple[str, list[Any]]:
return "type = 'CHAN' AND sender_name = ?", [sender_name]
@staticmethod
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
"""Parse paths JSON string to list of MessagePath objects."""
@@ -15,7 +46,7 @@ class MessageRepository:
try:
paths_data = json.loads(paths_json)
return [MessagePath(**p) for p in paths_data]
except (json.JSONDecodeError, TypeError, KeyError):
except (json.JSONDecodeError, TypeError, KeyError, ValueError):
return None
@staticmethod
@@ -26,6 +57,7 @@ class MessageRepository:
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,
@@ -34,16 +66,20 @@ class MessageRepository:
) -> 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.
Uses INSERT OR IGNORE to handle the message dedup indexes:
- channel messages dedupe by content/timestamp for echo reconciliation
- incoming direct messages dedupe by conversation/text/timestamp so
raw-packet and fallback observations merge onto one row
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}])
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(
"""
@@ -74,7 +110,10 @@ class MessageRepository:
@staticmethod
async def add_path(
message_id: int, path: str, received_at: int | None = None
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.
@@ -85,7 +124,10 @@ class MessageRepository:
# Atomic append: use json_insert to avoid read-modify-write race when
# multiple duplicate packets arrive concurrently for the same message.
new_entry = json.dumps({"path": path, "received_at": ts})
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(?)
@@ -121,13 +163,190 @@ class MessageRepository:
AND ? LIKE conversation_key || '%'
AND (
SELECT COUNT(*) FROM contacts
WHERE public_key LIKE messages.conversation_key || '%'
WHERE length(public_key) = 64
AND 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
AND (
SELECT COUNT(*) FROM contacts
WHERE name = ?
) = 1
AND EXISTS (
SELECT 1 FROM contacts
WHERE public_key = ? AND name = ?
)""",
(public_key.lower(), name, name, 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 _unescape_search_quoted_value(value: str) -> str:
return value.replace('\\"', '"').replace("\\\\", "\\")
@staticmethod
def _parse_search_query(q: str) -> _SearchQuery:
user_terms: list[str] = []
channel_terms: list[str] = []
fragments: list[str] = []
last_end = 0
for match in MessageRepository._SEARCH_OPERATOR_RE.finditer(q):
fragments.append(q[last_end : match.start()])
raw_value = match.group(2) if match.group(2) is not None else match.group(3) or ""
value = MessageRepository._unescape_search_quoted_value(raw_value)
if match.group(1).lower() == "user":
user_terms.append(value)
else:
channel_terms.append(value)
last_end = match.end()
if not user_terms and not channel_terms:
return MessageRepository._SearchQuery(free_text=q, user_terms=[], channel_terms=[])
fragments.append(q[last_end:])
free_text = " ".join(fragment.strip() for fragment in fragments if fragment.strip())
return MessageRepository._SearchQuery(
free_text=free_text,
user_terms=user_terms,
channel_terms=channel_terms,
)
@staticmethod
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@staticmethod
def _looks_like_hex_prefix(value: str) -> bool:
return bool(value) and all(ch in "0123456789abcdefABCDEF" for ch in value)
@staticmethod
def _build_channel_scope_clause(value: str) -> tuple[str, list[Any]]:
params: list[Any] = [value]
clause = "(messages.type = 'CHAN' AND (channels.name = ? COLLATE NOCASE"
if MessageRepository._looks_like_hex_prefix(value):
if len(value) == 32:
clause += " OR UPPER(messages.conversation_key) = ?"
params.append(value.upper())
else:
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
params.append(f"{MessageRepository._escape_like(value.upper())}%")
clause += "))"
return clause, params
@staticmethod
def _build_user_scope_clause(value: str) -> tuple[str, list[Any]]:
params: list[Any] = [value, value]
clause = (
"((messages.type = 'PRIV' AND contacts.name = ? COLLATE NOCASE)"
" OR (messages.type = 'CHAN' AND sender_name = ? COLLATE NOCASE)"
)
if MessageRepository._looks_like_hex_prefix(value):
lower_value = value.lower()
priv_key_clause: str
chan_key_clause: str
if len(value) == 64:
priv_key_clause = "LOWER(messages.conversation_key) = ?"
chan_key_clause = "LOWER(sender_key) = ?"
params.extend([lower_value, lower_value])
else:
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
params.extend([escaped_prefix, escaped_prefix])
clause += (
f" OR (messages.type = 'PRIV' AND {priv_key_clause})"
f" OR (messages.type = 'CHAN' AND sender_key IS NOT NULL AND {chan_key_clause})"
)
clause += ")"
return clause, params
@staticmethod
def _build_blocked_incoming_clause(
message_alias: str = "",
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
) -> tuple[str, list[Any]]:
prefix = f"{message_alias}." if message_alias else ""
blocked_matchers: list[str] = []
params: list[Any] = []
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
blocked_matchers.append(
f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))"
)
params.extend(blocked_keys)
blocked_matchers.append(
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
f" AND LOWER({prefix}sender_key) IN ({placeholders}))"
)
params.extend(blocked_keys)
if blocked_names:
placeholders = ",".join("?" for _ in blocked_names)
blocked_matchers.append(
f"({prefix}sender_name IS NOT NULL AND {prefix}sender_name IN ({placeholders}))"
)
params.extend(blocked_names)
if not blocked_matchers:
return "", []
return f"NOT ({prefix}outgoing = 0 AND ({' OR '.join(blocked_matchers)}))", params
@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,
@@ -136,57 +355,171 @@ class MessageRepository:
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"
search_query = MessageRepository._parse_search_query(q) if q else None
query = (
"SELECT messages.* FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
"LEFT JOIN channels ON messages.type = 'CHAN' "
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
"WHERE 1=1"
)
params: list[Any] = []
blocked_clause, blocked_params = MessageRepository._build_blocked_incoming_clause(
"messages", blocked_keys, blocked_names
)
if blocked_clause:
query += f" AND {blocked_clause}"
params.extend(blocked_params)
if msg_type:
query += " AND type = ?"
query += " AND messages.type = ?"
params.append(msg_type)
if conversation_key:
normalized_key = conversation_key
# Prefer exact matching for full keys.
if len(conversation_key) == 64:
normalized_key = conversation_key.lower()
query += " AND conversation_key = ?"
params.append(normalized_key)
elif len(conversation_key) == 32:
normalized_key = conversation_key.upper()
query += " AND conversation_key = ?"
params.append(normalized_key)
else:
# Prefix match is only for legacy/partial key callers.
query += " AND conversation_key LIKE ?"
params.append(f"{conversation_key}%")
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
query += f" {clause.replace('conversation_key', 'messages.conversation_key')}"
params.append(norm_key)
if before is not None and before_id is not None:
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
params.extend([before, before, before_id])
if search_query and search_query.user_terms:
scope_clauses: list[str] = []
for term in search_query.user_terms:
clause, clause_params = MessageRepository._build_user_scope_clause(term)
scope_clauses.append(clause)
params.extend(clause_params)
query += f" AND ({' OR '.join(scope_clauses)})"
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)
if search_query and search_query.channel_terms:
scope_clauses = []
for term in search_query.channel_terms:
clause, clause_params = MessageRepository._build_channel_scope_clause(term)
scope_clauses.append(clause)
params.extend(clause_params)
query += f" AND ({' OR '.join(scope_clauses)})"
if search_query and search_query.free_text:
escaped_q = MessageRepository._escape_like(search_query.free_text)
query += " AND messages.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 (messages.received_at > ? OR (messages.received_at = ? AND messages.id > ?))"
)
params.extend([after, after, after_id])
query += " ORDER BY messages.received_at ASC, messages.id ASC LIMIT ?"
params.append(limit)
else:
if before is not None and before_id is not None:
query += (
" AND (messages.received_at < ?"
" OR (messages.received_at = ? AND messages.id < ?))"
)
params.extend([before, before, before_id])
query += " ORDER BY messages.received_at DESC, messages.id DESC LIMIT ?"
params.append(limit)
if before is None or before_id is None:
query += " OFFSET ?"
params.append(offset)
cursor = await db.conn.execute(query, params)
rows = await cursor.fetchall()
return [
Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
paths=MessageRepository._parse_paths(row["paths"]),
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=row["acked"],
)
for row in rows
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)
blocked_clause, blocked_params = MessageRepository._build_blocked_incoming_clause(
blocked_keys=blocked_keys, blocked_names=blocked_names
)
if blocked_clause:
where_parts.append(blocked_clause)
base_params.extend(blocked_params)
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:
@@ -212,31 +545,14 @@ class MessageRepository:
async def get_by_id(message_id: int) -> "Message | None":
"""Look up a message by its ID."""
cursor = await db.conn.execute(
"""
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
paths, txt_type, signature, outgoing, acked
FROM messages
WHERE id = ?
""",
"SELECT * FROM messages WHERE id = ?",
(message_id,),
)
row = await cursor.fetchone()
if not row:
return None
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"],
)
return MessageRepository._row_to_message(row)
@staticmethod
async def get_by_content(
@@ -244,65 +560,57 @@ class MessageRepository:
conversation_key: str,
text: str,
sender_timestamp: int | None,
outgoing: bool | None = 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
query = """
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),
)
"""
params: list[Any] = [msg_type, conversation_key, text, sender_timestamp, sender_timestamp]
if outgoing is not None:
query += " AND outgoing = ?"
params.append(1 if outgoing else 0)
query += " ORDER BY id ASC"
cursor = await db.conn.execute(query, params)
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"],
)
return MessageRepository._row_to_message(row)
@staticmethod
async def get_unread_counts(name: str | None = None) -> dict:
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.
Dict with 'counts', 'mentions', 'last_message_times', and 'last_read_ats' keys.
"""
counts: dict[str, int] = {}
mention_flags: dict[str, bool] = {}
last_message_times: dict[str, int] = {}
last_read_ats: dict[str, int | None] = {}
mention_token = f"@[{name}]" if name else None
blocked_clause, blocked_params = MessageRepository._build_blocked_incoming_clause(
"m", blocked_keys, blocked_names
)
blocked_sql = f" AND {blocked_clause}" if blocked_clause else ""
# Channel unreads
cursor = await db.conn.execute(
"""
f"""
SELECT m.conversation_key,
COUNT(*) as unread_count,
SUM(CASE
@@ -313,9 +621,10 @@ class MessageRepository:
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)
{blocked_sql}
GROUP BY m.conversation_key
""",
(mention_token or "", mention_token or ""),
(mention_token or "", mention_token or "", *blocked_params),
)
rows = await cursor.fetchall()
for row in rows:
@@ -326,7 +635,7 @@ class MessageRepository:
# Contact unreads
cursor = await db.conn.execute(
"""
f"""
SELECT m.conversation_key,
COUNT(*) as unread_count,
SUM(CASE
@@ -337,9 +646,10 @@ class MessageRepository:
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)
{blocked_sql}
GROUP BY m.conversation_key
""",
(mention_token or "", mention_token or ""),
(mention_token or "", mention_token or "", *blocked_params),
)
rows = await cursor.fetchall()
for row in rows:
@@ -348,13 +658,41 @@ class MessageRepository:
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 key, last_read_at
FROM channels
"""
)
rows = await cursor.fetchall()
for row in rows:
last_read_ats[f"channel-{row['key']}"] = row["last_read_at"]
cursor = await db.conn.execute(
"""
SELECT public_key, last_read_at
FROM contacts
"""
)
rows = await cursor.fetchall()
for row in rows:
last_read_ats[f"contact-{row['public_key']}"] = row["last_read_at"]
# Last message times for all conversations (including read ones),
# excluding blocked incoming traffic so refresh matches live WS behavior.
last_time_clause, last_time_params = MessageRepository._build_blocked_incoming_clause(
blocked_keys=blocked_keys, blocked_names=blocked_names
)
last_time_where_sql = f"WHERE {last_time_clause}" if last_time_clause else ""
cursor = await db.conn.execute(
f"""
SELECT type, conversation_key, MAX(received_at) as last_message_time
FROM messages
{last_time_where_sql}
GROUP BY type, conversation_key
"""
""",
last_time_params,
)
rows = await cursor.fetchall()
for row in rows:
@@ -366,6 +704,7 @@ class MessageRepository:
"counts": counts,
"mentions": mention_flags,
"last_message_times": last_message_times,
"last_read_ats": last_read_ats,
}
@staticmethod
@@ -388,6 +727,105 @@ class MessageRepository:
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def count_channel_messages_by_sender_name(sender_name: str) -> int:
"""Count channel messages attributed to a display name."""
cursor = await db.conn.execute(
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_name = ?",
(sender_name,),
)
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def get_first_channel_message_by_sender_name(sender_name: str) -> int | None:
"""Get the earliest stored channel message timestamp for a display name."""
cursor = await db.conn.execute(
"SELECT MIN(received_at) AS first_seen FROM messages WHERE type = 'CHAN' AND sender_name = ?",
(sender_name,),
)
row = await cursor.fetchone()
return row["first_seen"] if row and row["first_seen"] is not None else None
@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 count_channels_with_incoming_messages() -> int:
"""Count distinct channel conversations with at least one incoming message."""
cursor = await db.conn.execute(
"""
SELECT COUNT(DISTINCT conversation_key) AS cnt
FROM messages
WHERE type = 'CHAN' AND outgoing = 0
"""
)
row = await cursor.fetchone()
return int(row["cnt"]) if row and row["cnt"] is not None else 0
@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.
@@ -409,3 +847,135 @@ class MessageRepository:
)
rows = await cursor.fetchall()
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
@staticmethod
async def get_most_active_rooms_by_sender_name(
sender_name: str, limit: int = 5
) -> list[tuple[str, str, int]]:
"""Get channels where a display name has sent the most messages."""
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_name = ?
GROUP BY m.conversation_key
ORDER BY cnt DESC
LIMIT ?
""",
(sender_name, limit),
)
rows = await cursor.fetchall()
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
@staticmethod
async def _get_activity_hour_buckets(where_sql: str, params: list[Any]) -> dict[int, int]:
cursor = await db.conn.execute(
f"""
SELECT received_at / 3600 AS hour_bucket, COUNT(*) AS cnt
FROM messages
WHERE {where_sql}
GROUP BY hour_bucket
""",
params,
)
rows = await cursor.fetchall()
return {int(row["hour_bucket"]): row["cnt"] for row in rows}
@staticmethod
def _build_hourly_activity(
hour_counts: dict[int, int], now: int
) -> list[ContactAnalyticsHourlyBucket]:
current_hour = now // 3600
if hour_counts:
min_hour = min(hour_counts)
else:
min_hour = current_hour
buckets: list[ContactAnalyticsHourlyBucket] = []
for hour_bucket in range(current_hour - 23, current_hour + 1):
last_24h_count = hour_counts.get(hour_bucket, 0)
week_total = 0
week_samples = 0
all_time_total = 0
all_time_samples = 0
compare_hour = hour_bucket
while compare_hour >= min_hour:
count = hour_counts.get(compare_hour, 0)
all_time_total += count
all_time_samples += 1
if week_samples < 7:
week_total += count
week_samples += 1
compare_hour -= 24
buckets.append(
ContactAnalyticsHourlyBucket(
bucket_start=hour_bucket * 3600,
last_24h_count=last_24h_count,
last_week_average=round(week_total / week_samples, 2) if week_samples else 0,
all_time_average=round(all_time_total / all_time_samples, 2)
if all_time_samples
else 0,
)
)
return buckets
@staticmethod
async def _get_weekly_activity(
where_sql: str,
params: list[Any],
now: int,
weeks: int = 26,
) -> list[ContactAnalyticsWeeklyBucket]:
bucket_seconds = 7 * 24 * 3600
current_day_start = (now // 86400) * 86400
start = current_day_start - (weeks - 1) * bucket_seconds
cursor = await db.conn.execute(
f"""
SELECT (received_at - ?) / ? AS bucket_idx, COUNT(*) AS cnt
FROM messages
WHERE {where_sql} AND received_at >= ?
GROUP BY bucket_idx
""",
[start, bucket_seconds, *params, start],
)
rows = await cursor.fetchall()
counts = {int(row["bucket_idx"]): row["cnt"] for row in rows}
return [
ContactAnalyticsWeeklyBucket(
bucket_start=start + bucket_idx * bucket_seconds,
message_count=counts.get(bucket_idx, 0),
)
for bucket_idx in range(weeks)
]
@staticmethod
async def get_contact_activity_series(
public_key: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get combined DM + channel activity series for a keyed contact."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._contact_activity_filter(public_key)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly
@staticmethod
async def get_sender_name_activity_series(
sender_name: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get channel-only activity series for a sender name."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._name_activity_filter(sender_name)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly

View File

@@ -109,6 +109,18 @@ class RawPacketRepository:
)
await db.conn.commit()
@staticmethod
async def get_linked_message_id(packet_id: int) -> int | None:
"""Return the linked message ID for a raw packet, if any."""
cursor = await db.conn.execute(
"SELECT message_id FROM raw_packets WHERE id = ?",
(packet_id,),
)
row = await cursor.fetchone()
if not row:
return None
return row["message_id"]
@staticmethod
async def prune_old_undecrypted(max_age_days: int) -> int:
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""

View File

@@ -4,7 +4,8 @@ import time
from typing import Any, Literal
from app.database import db
from app.models import AppSettings, BotConfig, Favorite
from app.models import AppSettings, Favorite
from app.path_utils import parse_packet_envelope
logger = logging.getLogger(__name__)
@@ -26,10 +27,8 @@ class AppSettingsRepository:
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, bots,
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
mqtt_publish_messages, mqtt_publish_raw_packets
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names
FROM app_settings WHERE id = 1
"""
)
@@ -65,19 +64,21 @@ class AppSettingsRepository:
)
last_message_times = {}
# Parse bots JSON
bots: list[BotConfig] = []
if row["bots"]:
# Parse blocked_keys JSON
blocked_keys: list[str] = []
if row["blocked_keys"]:
try:
bots_data = json.loads(row["bots"])
bots = [BotConfig(**b) for b in bots_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse bots JSON, using empty list: %s (data=%r)",
e,
row["bots"][:100] if row["bots"] else None,
)
bots = []
blocked_keys = json.loads(row["blocked_keys"])
except (json.JSONDecodeError, TypeError):
blocked_keys = []
# 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"]
@@ -93,16 +94,9 @@ class AppSettingsRepository:
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
bots=bots,
mqtt_broker_host=row["mqtt_broker_host"] or "",
mqtt_broker_port=row["mqtt_broker_port"] or 1883,
mqtt_username=row["mqtt_username"] or "",
mqtt_password=row["mqtt_password"] or "",
mqtt_use_tls=bool(row["mqtt_use_tls"]),
mqtt_tls_insecure=bool(row["mqtt_tls_insecure"]),
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
)
@staticmethod
@@ -115,16 +109,9 @@ class AppSettingsRepository:
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
last_advert_time: int | None = None,
bots: list[BotConfig] | None = None,
mqtt_broker_host: str | None = None,
mqtt_broker_port: int | None = None,
mqtt_username: str | None = None,
mqtt_password: str | None = None,
mqtt_use_tls: bool | None = None,
mqtt_tls_insecure: bool | None = None,
mqtt_topic_prefix: str | None = None,
mqtt_publish_messages: bool | None = None,
mqtt_publish_raw_packets: bool | None = None,
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 = []
@@ -163,46 +150,17 @@ class AppSettingsRepository:
updates.append("last_advert_time = ?")
params.append(last_advert_time)
if bots is not None:
updates.append("bots = ?")
bots_json = json.dumps([b.model_dump() for b in bots])
params.append(bots_json)
if flood_scope is not None:
updates.append("flood_scope = ?")
params.append(flood_scope)
if mqtt_broker_host is not None:
updates.append("mqtt_broker_host = ?")
params.append(mqtt_broker_host)
if blocked_keys is not None:
updates.append("blocked_keys = ?")
params.append(json.dumps(blocked_keys))
if mqtt_broker_port is not None:
updates.append("mqtt_broker_port = ?")
params.append(mqtt_broker_port)
if mqtt_username is not None:
updates.append("mqtt_username = ?")
params.append(mqtt_username)
if mqtt_password is not None:
updates.append("mqtt_password = ?")
params.append(mqtt_password)
if mqtt_use_tls is not None:
updates.append("mqtt_use_tls = ?")
params.append(1 if mqtt_use_tls else 0)
if mqtt_tls_insecure is not None:
updates.append("mqtt_tls_insecure = ?")
params.append(1 if mqtt_tls_insecure else 0)
if mqtt_topic_prefix is not None:
updates.append("mqtt_topic_prefix = ?")
params.append(mqtt_topic_prefix)
if mqtt_publish_messages is not None:
updates.append("mqtt_publish_messages = ?")
params.append(1 if mqtt_publish_messages else 0)
if mqtt_publish_raw_packets is not None:
updates.append("mqtt_publish_raw_packets = ?")
params.append(1 if mqtt_publish_raw_packets else 0)
if 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"
@@ -232,6 +190,27 @@ class AppSettingsRepository:
]
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],
@@ -291,6 +270,53 @@ class StatisticsRepository:
"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."""
@@ -370,6 +396,7 @@ class StatisticsRepository:
# 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,
@@ -384,4 +411,5 @@ class StatisticsRepository:
"total_outgoing": total_outgoing,
"contacts_heard": contacts_heard,
"repeaters_heard": repeaters_heard,
"path_hash_width_24h": path_hash_width_24h,
}

View File

@@ -1,20 +1,28 @@
import logging
from hashlib import sha256
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import Channel
from app.radio import radio_manager
from app.radio_sync import upsert_channel_from_radio_slot
from app.repository import ChannelRepository
from app.channel_constants import (
PUBLIC_CHANNEL_KEY,
PUBLIC_CHANNEL_NAME,
is_public_channel_key,
is_public_channel_name,
)
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
def _broadcast_channel_update(channel: Channel) -> None:
broadcast_event("channel", channel.model_dump())
class CreateChannelRequest(BaseModel):
name: str = Field(min_length=1, max_length=32)
key: str | None = Field(
@@ -23,19 +31,34 @@ class CreateChannelRequest(BaseModel):
)
class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope"
)
@router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]:
"""List all channels from the database."""
return await ChannelRepository.get_all()
@router.get("/{key}", response_model=Channel)
async def get_channel(key: str) -> Channel:
"""Get a specific channel by key (32-char hex string)."""
@router.get("/{key}/detail", response_model=ChannelDetail)
async def get_channel_detail(key: str) -> ChannelDetail:
"""Get comprehensive channel profile data with message statistics."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
return channel
stats = await MessageRepository.get_channel_stats(channel.key)
return ChannelDetail(
channel=channel,
message_counts=ChannelMessageCounts(**stats["message_counts"]),
first_message_at=stats["first_message_at"],
unique_sender_count=stats["unique_sender_count"],
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
)
@router.post("", response_model=Channel)
@@ -45,10 +68,31 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
Channels are NOT pushed to radio on creation. They are loaded to the radio
automatically when sending a message (see messages.py send_channel_message).
"""
is_hashtag = request.name.startswith("#")
requested_name = request.name
is_hashtag = requested_name.startswith("#")
# Determine the channel secret
if request.key and not is_hashtag:
# Reserve the canonical Public room so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
key_hex = PUBLIC_CHANNEL_KEY
channel_name = PUBLIC_CHANNEL_NAME
is_hashtag = False
elif request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
@@ -57,51 +101,35 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
channel_name = requested_name
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(request.name.encode("utf-8")).digest()[:16]
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
channel_name = requested_name
key_hex = key_bytes.hex().upper()
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, request.name, is_hashtag)
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
# Store in database only - radio sync happens at send time
await ChannelRepository.upsert(
key=key_hex,
name=request.name,
name=channel_name,
is_hashtag=is_hashtag,
on_radio=False,
)
return Channel(
key=key_hex,
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
)
stored = await ChannelRepository.get_by_key(key_hex)
if stored is None:
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
@router.post("/sync")
async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, le=40)) -> dict:
"""Sync channels from the radio to the database."""
require_connected()
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
count = 0
async with radio_manager.radio_operation("sync_channels_from_radio") as mc:
for idx in range(max_channels):
result = await mc.commands.get_channel(idx)
if result.type == EventType.CHANNEL_INFO:
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
if key_hex is not None:
count += 1
logger.debug(
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
)
logger.info("Synced %d channels from radio", count)
return {"synced": count}
_broadcast_channel_update(stored)
return stored
@router.post("/{key}/mark-read")
@@ -118,6 +146,28 @@ async def mark_channel_read(key: str) -> dict:
return {"status": "ok", "key": channel.key}
@router.post("/{key}/flood-scope-override", response_model=Channel)
async def set_channel_flood_scope_override(
key: str, request: ChannelFloodScopeOverrideRequest
) -> Channel:
"""Set or clear a per-channel flood-scope override."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
override = normalize_region_scope(request.flood_scope_override) or None
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
refreshed = await ChannelRepository.get_by_key(channel.key)
if refreshed is None:
raise HTTPException(status_code=500, detail="Channel disappeared after update")
broadcast_event("channel", refreshed.model_dump())
return refreshed
@router.delete("/{key}")
async def delete_channel(key: str) -> dict:
"""Delete a channel from the database by key.
@@ -125,6 +175,14 @@ async def delete_channel(key: str) -> dict:
Note: This does not clear the channel from the radio. The radio's channel
slots are managed separately (channels are loaded temporarily when sending).
"""
if is_public_channel_key(key):
raise HTTPException(
status_code=400, detail="The canonical Public channel cannot be deleted"
)
logger.info("Deleting channel %s from database", key)
await ChannelRepository.delete(key)
broadcast_event("channel_deleted", {"key": key})
return {"status": "ok"}

View File

@@ -1,5 +1,7 @@
import asyncio
import logging
import random
from contextlib import suppress
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType
@@ -8,15 +10,18 @@ from app.dependencies import require_connected
from app.models import (
Contact,
ContactActiveRoom,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
ContactAnalytics,
ContactRoutingOverrideRequest,
ContactUpsert,
CreateContactRequest,
NearestRepeater,
PathDiscoveryResponse,
PathDiscoveryRoute,
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
from app.radio import radio_manager
from app.path_utils import parse_explicit_hop_route
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactAdvertPathRepository,
@@ -24,6 +29,11 @@ from app.repository import (
ContactRepository,
MessageRepository,
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.radio_runtime import radio_runtime as radio_manager
logger = logging.getLogger(__name__)
@@ -59,6 +69,149 @@ async def _ensure_on_radio(mc, contact: Contact) -> None:
)
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
"""Best-effort push the current effective route to the radio when connected."""
if not radio_manager.is_connected:
return
try:
async with radio_manager.radio_operation(operation_name) as mc:
result = await mc.commands.add_contact(contact.to_radio_dict())
if result is not None and result.type == EventType.ERROR:
logger.warning(
"Failed to push updated routing to radio for %s: %s",
contact.public_key[:12],
result.payload,
)
except Exception:
logger.warning(
"Failed to push updated routing to radio for %s",
contact.public_key[:12],
exc_info=True,
)
async def _broadcast_contact_update(contact: Contact) -> None:
from app.websocket import broadcast_event
broadcast_event("contact", contact.model_dump())
async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None:
from app.websocket import broadcast_event
for old_key in previous_public_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": contact.model_dump(),
},
)
def _path_hash_mode_from_hop_width(hop_width: object) -> int:
if not isinstance(hop_width, int):
return 0
return max(0, min(hop_width - 1, 2))
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
hourly_activity, weekly_activity = await MessageRepository.get_contact_activity_series(
contact.public_key
)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
first_hop_stats: dict[str, dict] = {}
for p in advert_paths:
prefix = p.next_hop
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
return ContactAnalytics(
lookup_type="contact",
name=contact.name or contact.public_key[:12],
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
includes_direct_messages=True,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
async def _build_name_only_contact_analytics(name: str) -> ContactAnalytics:
chan_count = await MessageRepository.count_channel_messages_by_sender_name(name)
name_first_seen_at = await MessageRepository.get_first_channel_message_by_sender_name(name)
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(name)
hourly_activity, weekly_activity = await MessageRepository.get_sender_name_activity_series(name)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
for key, room_name, count in active_rooms_raw
]
return ContactAnalytics(
lookup_type="name",
name=name,
name_first_seen_at=name_first_seen_at,
channel_message_count=chan_count,
includes_direct_messages=False,
most_active_rooms=most_active_rooms,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
@router.get("", response_model=list[Contact])
async def list_contacts(
limit: int = Query(default=100, ge=1, le=1000),
@@ -82,6 +235,26 @@ async def list_repeater_advert_paths(
)
@router.get("/analytics", response_model=ContactAnalytics)
async def get_contact_analytics(
public_key: str | None = Query(default=None),
name: str | None = Query(default=None, min_length=1, max_length=200),
) -> ContactAnalytics:
"""Get unified contact analytics for either a keyed contact or a sender name."""
if bool(public_key) == bool(name):
raise HTTPException(status_code=400, detail="Specify exactly one of public_key or name")
if public_key:
contact = await _resolve_contact_or_404(public_key)
return await _build_keyed_contact_analytics(contact)
assert name is not None
normalized_name = name.strip()
if not normalized_name:
raise HTTPException(status_code=400, detail="name is required")
return await _build_name_only_contact_analytics(normalized_name)
@router.post("", response_model=Contact)
async def create_contact(
request: CreateContactRequest, background_tasks: BackgroundTasks
@@ -102,239 +275,60 @@ async def create_contact(
if existing:
# Update name if provided
if request.name:
await ContactRepository.upsert(
{
"public_key": existing.public_key,
"name": request.name,
"type": existing.type,
"flags": existing.flags,
"last_path": existing.last_path,
"last_path_len": existing.last_path_len,
"last_advert": existing.last_advert,
"lat": existing.lat,
"lon": existing.lon,
"last_seen": existing.last_seen,
"on_radio": existing.on_radio,
"last_contacted": existing.last_contacted,
}
)
await ContactRepository.upsert(existing.to_upsert(name=request.name))
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key,
log=logger,
)
if promoted_keys:
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
await _broadcast_contact_resolution(promoted_keys, existing)
# Trigger historical decryption if requested (even for existing contacts)
if request.try_historical:
await start_historical_dm_decryption(
background_tasks, request.public_key, request.name or existing.name
)
await _broadcast_contact_update(existing)
return existing
# Create new contact
lower_key = request.public_key.lower()
contact_data = {
"public_key": lower_key,
"name": request.name,
"type": 0, # Unknown
"flags": 0,
"last_path": None,
"last_path_len": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
}
await ContactRepository.upsert(contact_data)
contact_upsert = ContactUpsert(
public_key=lower_key,
name=request.name,
on_radio=False,
)
await ContactRepository.upsert(contact_upsert)
logger.info("Created contact %s", lower_key[:12])
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=lower_key,
log=logger,
)
# Promote any prefix-stored messages to this full key
claimed = await MessageRepository.claim_prefix_messages(lower_key)
if claimed > 0:
logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12])
await reconcile_contact_messages(
public_key=lower_key,
contact_name=request.name,
log=logger,
)
# Trigger historical decryption if requested
if request.try_historical:
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
return Contact(**contact_data)
@router.get("/{public_key}/detail", response_model=ContactDetail)
async def get_contact_detail(public_key: str) -> ContactDetail:
"""Get comprehensive contact profile data.
Returns contact info, name history, message counts, most active rooms,
advertisement paths, advert frequency, and nearest repeaters.
"""
contact = await _resolve_contact_or_404(public_key)
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
# Compute advert observation rate (observations/hour) from path data.
# Note: a single advertisement can arrive via multiple paths, so this counts
# RF observations, not unique advertisement broadcasts.
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
for p in advert_paths:
if p.path and len(p.path) >= 2:
prefix = p.path[:2].lower()
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
# Resolve all first-hop prefixes to contacts in a single query
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
return ContactDetail(
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
)
@router.get("/{public_key}", response_model=Contact)
async def get_contact(public_key: str) -> Contact:
"""Get a specific contact by public key or prefix."""
return await _resolve_contact_or_404(public_key)
@router.get("/{public_key}/advert-paths", response_model=list[ContactAdvertPath])
async def get_contact_advert_paths(
public_key: str,
limit: int = Query(default=10, ge=1, le=50),
) -> list[ContactAdvertPath]:
"""List recent unique advert paths for a contact."""
contact = await _resolve_contact_or_404(public_key)
return await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key, limit)
@router.post("/sync")
async def sync_contacts_from_radio() -> dict:
"""Sync contacts from the radio to the database."""
require_connected()
logger.info("Syncing contacts from radio")
async with radio_manager.radio_operation("sync_contacts_from_radio") as mc:
result = await mc.commands.get_contacts()
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to get contacts: {result.payload}")
contacts = result.payload
count = 0
for public_key, contact_data in contacts.items():
lower_key = public_key.lower()
await ContactRepository.upsert(
Contact.from_radio_dict(lower_key, contact_data, on_radio=True)
)
claimed = await MessageRepository.claim_prefix_messages(lower_key)
if claimed > 0:
logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12])
count += 1
logger.info("Synced %d contacts from radio", count)
return {"synced": count}
@router.post("/{public_key}/remove-from-radio")
async def remove_contact_from_radio(public_key: str) -> dict:
"""Remove a contact from the radio (keeps it in database)."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
async with radio_manager.radio_operation("remove_contact_from_radio") as mc:
# Get the contact from radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not radio_contact:
# Already not on radio
await ContactRepository.set_on_radio(contact.public_key, False)
return {"status": "ok", "message": "Contact was not on radio"}
logger.info("Removing contact %s from radio", contact.public_key[:12])
result = await mc.commands.remove_contact(radio_contact)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to remove contact: {result.payload}"
)
await ContactRepository.set_on_radio(contact.public_key, False)
return {"status": "ok"}
@router.post("/{public_key}/add-to-radio")
async def add_contact_to_radio(public_key: str) -> dict:
"""Add a contact from the database to the radio."""
require_connected()
contact = await _resolve_contact_or_404(public_key, "Contact not found in database")
async with radio_manager.radio_operation("add_contact_to_radio") as mc:
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
return {"status": "ok", "message": "Contact already on radio"}
logger.info("Adding contact %s to radio", contact.public_key[:12])
result = await mc.commands.add_contact(contact.to_radio_dict())
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to add contact: {result.payload}")
await ContactRepository.set_on_radio(contact.public_key, True)
return {"status": "ok"}
stored = await ContactRepository.get_by_key(lower_key)
if stored is None:
raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded")
await _broadcast_contact_update(stored)
await _broadcast_contact_resolution(promoted_keys, stored)
return stored
@router.post("/{public_key}/mark-read")
@@ -368,6 +362,10 @@ async def delete_contact(public_key: str) -> dict:
await ContactRepository.delete(contact.public_key)
logger.info("Deleted contact %s", contact.public_key[:12])
from app.websocket import broadcast_event
broadcast_event("contact_deleted", {"public_key": contact.public_key})
return {"status": "ok"}
@@ -431,29 +429,132 @@ async def request_trace(public_key: str) -> TraceResponse:
return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len)
@router.post("/{public_key}/reset-path")
async def reset_contact_path(public_key: str) -> dict:
"""Reset a contact's routing path to flood."""
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
"""Discover the current forward and return paths to a known contact."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
pubkey_prefix = contact.public_key[:12]
async with radio_manager.radio_operation("request_path_discovery", pause_polling=True) as mc:
await _ensure_on_radio(mc, contact)
response_task = asyncio.create_task(
mc.wait_for_event(
EventType.PATH_RESPONSE,
attribute_filters={"pubkey_pre": pubkey_prefix},
timeout=15,
)
)
try:
result = await mc.commands.send_path_discovery(contact.public_key)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to send path discovery: {result.payload}",
)
event = await response_task
finally:
if not response_task.done():
response_task.cancel()
with suppress(asyncio.CancelledError):
await response_task
if event is None:
raise HTTPException(status_code=504, detail="No path discovery response heard")
payload = event.payload
forward_path = str(payload.get("out_path") or "")
forward_len = int(payload.get("out_path_len") or 0)
forward_mode = _path_hash_mode_from_hop_width(payload.get("out_path_hash_len"))
return_path = str(payload.get("in_path") or "")
return_len = int(payload.get("in_path_len") or 0)
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
await ContactRepository.update_direct_path(
contact.public_key,
forward_path,
forward_len,
forward_mode,
)
refreshed_contact = await _resolve_contact_or_404(contact.public_key)
try:
sync_result = await mc.commands.add_contact(refreshed_contact.to_radio_dict())
if sync_result is not None and sync_result.type == EventType.ERROR:
logger.warning(
"Failed to sync discovered path back to radio for %s: %s",
refreshed_contact.public_key[:12],
sync_result.payload,
)
except Exception:
logger.warning(
"Failed to sync discovered path back to radio for %s",
refreshed_contact.public_key[:12],
exc_info=True,
)
await _broadcast_contact_update(refreshed_contact)
return PathDiscoveryResponse(
contact=refreshed_contact,
forward_path=PathDiscoveryRoute(
path=forward_path,
path_len=forward_len,
path_hash_mode=forward_mode,
),
return_path=PathDiscoveryRoute(
path=return_path,
path_len=return_len,
path_hash_mode=return_mode,
),
)
@router.post("/{public_key}/routing-override")
async def set_contact_routing_override(
public_key: str, request: ContactRoutingOverrideRequest
) -> dict:
"""Set, force, or clear an explicit routing override for a contact."""
contact = await _resolve_contact_or_404(public_key)
await ContactRepository.update_path(contact.public_key, "", -1)
logger.info("Reset path to flood for %s", contact.public_key[:12])
# Push the updated path to radio if connected and contact is on radio
if radio_manager.is_connected and contact.on_radio:
route_text = request.route.strip()
if route_text == "":
await ContactRepository.clear_routing_override(contact.public_key)
logger.info(
"Cleared routing override for %s",
contact.public_key[:12],
)
elif route_text == "-1":
await ContactRepository.set_routing_override(contact.public_key, "", -1, -1)
logger.info("Set forced flood routing override for %s", contact.public_key[:12])
elif route_text == "0":
await ContactRepository.set_routing_override(contact.public_key, "", 0, 0)
logger.info("Set forced direct routing override for %s", contact.public_key[:12])
else:
try:
updated = await ContactRepository.get_by_key(contact.public_key)
if updated:
async with radio_manager.radio_operation("reset_path_on_radio") as mc:
await mc.commands.add_contact(updated.to_radio_dict())
except Exception:
logger.warning("Failed to push flood path to radio for %s", contact.public_key[:12])
path_hex, path_len, hash_mode = parse_explicit_hop_route(route_text)
except ValueError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
# Broadcast updated contact so frontend refreshes
from app.websocket import broadcast_event
await ContactRepository.set_routing_override(
contact.public_key,
path_hex,
path_len,
hash_mode,
)
logger.info(
"Set explicit routing override for %s: %d hop(s), %d-byte IDs",
contact.public_key[:12],
path_len,
hash_mode + 1,
)
updated_contact = await ContactRepository.get_by_key(contact.public_key)
if updated_contact:
broadcast_event("contact", updated_contact.model_dump())
await _best_effort_push_contact_to_radio(updated_contact, "set_routing_override_on_radio")
await _broadcast_contact_update(updated_contact)
return {"status": "ok", "public_key": contact.public_key}

287
app/routers/debug.py Normal file
View File

@@ -0,0 +1,287 @@
import hashlib
import logging
import sys
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter
from meshcore import EventType
from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import MessageRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
logger = logging.getLogger(__name__)
router = APIRouter(tags=["debug"])
LOG_COPY_BOUNDARY_MESSAGE = "STOP COPYING HERE IF YOU DO NOT WANT TO INCLUDE LOGS BELOW"
LOG_COPY_BOUNDARY_LINE = "-" * 64
LOG_COPY_BOUNDARY_PREFIX = [
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_MESSAGE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
]
class DebugApplicationInfo(BaseModel):
version: str
version_source: str
commit_hash: str | None = None
commit_source: str | None = None
git_branch: str | None = None
git_dirty: bool | None = None
python_version: str
class DebugRuntimeInfo(BaseModel):
connection_info: str | None = None
connection_desired: bool
setup_in_progress: bool
setup_complete: bool
channels_with_incoming_messages: int
max_channels: int
path_hash_mode: int
path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int
remediation_flags: dict[str, bool]
class DebugContactAudit(BaseModel):
expected_and_found: int
expected_but_not_found: list[str]
found_but_not_expected: list[str]
class DebugChannelSlotMismatch(BaseModel):
slot_number: int
expected_sha256_of_room_key: str | None = None
actual_sha256_of_room_key: str | None = None
class DebugChannelAudit(BaseModel):
matched_slots: int
wrong_slots: list[DebugChannelSlotMismatch]
class DebugRadioProbe(BaseModel):
performed: bool
errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None
stats_radio: dict[str, Any] | None = None
contacts: DebugContactAudit | None = None
channels: DebugChannelAudit | None = None
class DebugSnapshotResponse(BaseModel):
captured_at: str
application: DebugApplicationInfo
health: HealthResponse
runtime: DebugRuntimeInfo
radio_probe: DebugRadioProbe
logs: list[str]
def _build_application_info() -> DebugApplicationInfo:
build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain")
return DebugApplicationInfo(
version=build_info.version,
version_source=build_info.version_source,
commit_hash=build_info.commit_hash,
commit_source=build_info.commit_source,
git_branch=git_output("rev-parse", "--abbrev-ref", "HEAD"),
git_dirty=(dirty_output is not None and dirty_output != ""),
python_version=sys.version.split()[0],
)
def _event_type_name(event: Any) -> str:
event_type = getattr(event, "type", None)
return getattr(event_type, "name", str(event_type))
def _sha256_hex(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _normalize_channel_secret(payload: dict[str, Any]) -> bytes:
secret = payload.get("channel_secret", b"")
if isinstance(secret, bytes):
return secret
return bytes(secret)
def _is_empty_channel_payload(payload: dict[str, Any]) -> bool:
name = payload.get("channel_name", "")
return not name or name == "\x00" * len(name)
def _observed_channel_key(event: Any) -> str | None:
if getattr(event, "type", None) != EventType.CHANNEL_INFO:
return None
payload = event.payload or {}
if _is_empty_channel_payload(payload):
return None
return _normalize_channel_secret(payload).hex().upper()
def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
if not device_info or "max_channels" not in device_info:
return None
try:
return int(device_info["max_channels"])
except (TypeError, ValueError):
return None
async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit:
expected_contacts = await get_contacts_selected_for_radio_sync()
expected_keys = {contact.public_key.lower() for contact in expected_contacts}
observed_keys = {public_key.lower() for public_key in observed_contacts_payload}
return DebugContactAudit(
expected_and_found=len(expected_keys & observed_keys),
expected_but_not_found=sorted(_sha256_hex(key) for key in (expected_keys - observed_keys)),
found_but_not_expected=sorted(_sha256_hex(key) for key in (observed_keys - expected_keys)),
)
async def _build_channel_audit(mc: Any, max_channels: int | None = None) -> DebugChannelAudit:
cache_key_by_slot = {
slot: channel_key for channel_key, slot in radio_runtime.get_channel_send_cache_snapshot()
}
matched_slots = 0
wrong_slots: list[DebugChannelSlotMismatch] = []
for slot in range(get_radio_channel_limit(max_channels)):
event = await mc.commands.get_channel(slot)
expected_key = cache_key_by_slot.get(slot)
observed_key = _observed_channel_key(event)
if expected_key == observed_key:
matched_slots += 1
continue
wrong_slots.append(
DebugChannelSlotMismatch(
slot_number=slot,
expected_sha256_of_room_key=_sha256_hex(expected_key) if expected_key else None,
actual_sha256_of_room_key=_sha256_hex(observed_key) if observed_key else None,
)
)
return DebugChannelAudit(
matched_slots=matched_slots,
wrong_slots=wrong_slots,
)
async def _probe_radio() -> DebugRadioProbe:
if not radio_runtime.is_connected:
return DebugRadioProbe(performed=False, errors=["Radio not connected"])
errors: list[str] = []
try:
async with radio_runtime.radio_operation(
"debug_support_snapshot",
suspend_auto_fetch=True,
) as mc:
device_info = None
stats_core = None
stats_radio = None
device_event = await mc.commands.send_device_query()
if getattr(device_event, "type", None) == EventType.DEVICE_INFO:
device_info = device_event.payload
else:
errors.append(f"send_device_query returned {_event_type_name(device_event)}")
core_event = await mc.commands.get_stats_core()
if getattr(core_event, "type", None) == EventType.STATS_CORE:
stats_core = core_event.payload
else:
errors.append(f"get_stats_core returned {_event_type_name(core_event)}")
radio_event = await mc.commands.get_stats_radio()
if getattr(radio_event, "type", None) == EventType.STATS_RADIO:
stats_radio = radio_event.payload
else:
errors.append(f"get_stats_radio returned {_event_type_name(radio_event)}")
contacts_event = await mc.commands.get_contacts()
observed_contacts_payload: dict[str, dict[str, Any]] = {}
if getattr(contacts_event, "type", None) != EventType.ERROR:
observed_contacts_payload = contacts_event.payload or {}
else:
errors.append(f"get_contacts returned {_event_type_name(contacts_event)}")
return DebugRadioProbe(
performed=True,
errors=errors,
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info,
stats_core=stats_core,
stats_radio=stats_radio,
contacts=await _build_contact_audit(observed_contacts_payload),
channels=await _build_channel_audit(
mc,
max_channels=_coerce_live_max_channels(device_info),
),
)
except Exception as exc:
logger.warning("Debug support snapshot radio probe failed: %s", exc, exc_info=True)
errors.append(str(exc))
return DebugRadioProbe(performed=False, errors=errors)
@router.get("/debug", response_model=DebugSnapshotResponse)
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
)
return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(),
application=_build_application_info(),
health=HealthResponse(**health_data),
runtime=DebugRuntimeInfo(
connection_info=radio_runtime.connection_info,
connection_desired=radio_runtime.connection_desired,
setup_in_progress=radio_runtime.is_setup_in_progress,
setup_complete=radio_runtime.is_setup_complete,
channels_with_incoming_messages=channels_with_incoming_messages,
max_channels=radio_runtime.max_channels,
path_hash_mode=radio_runtime.path_hash_mode,
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
remediation_flags={
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
},
),
radio_probe=radio_probe,
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
)

415
app/routers/fanout.py Normal file
View File

@@ -0,0 +1,415 @@
"""REST API for fanout config CRUD."""
import ast
import inspect
import logging
import re
import string
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.fanout.bot_exec import _analyze_bot_signature
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
_DEFAULT_COMMUNITY_MQTT_BROKER_HOST = "mqtt-us-v1.letsmesh.net"
_DEFAULT_COMMUNITY_MQTT_BROKER_PORT = 443
_DEFAULT_COMMUNITY_MQTT_TRANSPORT = "websockets"
_DEFAULT_COMMUNITY_MQTT_AUTH_MODE = "token"
_COMMUNITY_MQTT_TEMPLATE_FIELD_CANONICAL = {
"iata": "IATA",
"public_key": "PUBLIC_KEY",
}
_ALLOWED_COMMUNITY_MQTT_TRANSPORTS = {"tcp", "websockets"}
_ALLOWED_COMMUNITY_MQTT_AUTH_MODES = {"token", "password", "none"}
def _normalize_community_topic_template(topic_template: str) -> str:
"""Normalize Community MQTT topic template placeholders to canonical uppercase form."""
template = topic_template.strip() or _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE
parts: list[str] = []
try:
parsed = string.Formatter().parse(template)
for literal_text, field_name, format_spec, conversion in parsed:
parts.append(literal_text)
if field_name is None:
continue
normalized_field = _COMMUNITY_MQTT_TEMPLATE_FIELD_CANONICAL.get(field_name.lower())
if normalized_field is None:
raise HTTPException(
status_code=400,
detail=(
f"topic_template may only use {{IATA}} and {{PUBLIC_KEY}}; got {field_name}"
),
)
replacement = ["{", normalized_field]
if conversion:
replacement.extend(["!", conversion])
if format_spec:
replacement.extend([":", format_spec])
replacement.append("}")
parts.append("".join(replacement))
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid topic_template: {exc}") from None
return "".join(parts)
class FanoutConfigCreate(BaseModel):
type: str = Field(description="Integration type: 'mqtt_private' or 'mqtt_community'")
name: str = Field(min_length=1, description="User-assigned label")
config: dict = Field(default_factory=dict, description="Type-specific config blob")
scope: dict = Field(default_factory=dict, description="Scope controls")
enabled: bool = Field(default=True, description="Whether enabled on creation")
class FanoutConfigUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, description="Updated label")
config: dict | None = Field(default=None, description="Updated config blob")
scope: dict | None = Field(default=None, description="Updated scope controls")
enabled: bool | None = Field(default=None, description="Enable/disable toggle")
def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
"""Validate a config blob and return the canonical persisted form."""
normalized = dict(config)
if config_type == "mqtt_private":
_validate_mqtt_private_config(normalized)
elif config_type == "mqtt_community":
_validate_mqtt_community_config(normalized)
elif config_type == "bot":
_validate_bot_config(normalized)
elif config_type == "webhook":
_validate_webhook_config(normalized)
elif config_type == "apprise":
_validate_apprise_config(normalized)
elif config_type == "sqs":
_validate_sqs_config(normalized)
return normalized
def _validate_mqtt_private_config(config: dict) -> None:
"""Validate mqtt_private config blob."""
if not config.get("broker_host"):
raise HTTPException(status_code=400, detail="broker_host is required for mqtt_private")
port = config.get("broker_port", 1883)
if not isinstance(port, int) or port < 1 or port > 65535:
raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
def _validate_mqtt_community_config(config: dict) -> None:
"""Validate mqtt_community config blob. Normalizes IATA to uppercase."""
broker_host = str(config.get("broker_host", _DEFAULT_COMMUNITY_MQTT_BROKER_HOST)).strip()
if not broker_host:
broker_host = _DEFAULT_COMMUNITY_MQTT_BROKER_HOST
config["broker_host"] = broker_host
port = config.get("broker_port", _DEFAULT_COMMUNITY_MQTT_BROKER_PORT)
if not isinstance(port, int) or port < 1 or port > 65535:
raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
config["broker_port"] = port
transport = str(config.get("transport", _DEFAULT_COMMUNITY_MQTT_TRANSPORT)).strip().lower()
if transport not in _ALLOWED_COMMUNITY_MQTT_TRANSPORTS:
raise HTTPException(
status_code=400,
detail="transport must be 'websockets' or 'tcp'",
)
config["transport"] = transport
config["use_tls"] = bool(config.get("use_tls", True))
config["tls_verify"] = bool(config.get("tls_verify", True))
auth_mode = str(config.get("auth_mode", _DEFAULT_COMMUNITY_MQTT_AUTH_MODE)).strip().lower()
if auth_mode not in _ALLOWED_COMMUNITY_MQTT_AUTH_MODES:
raise HTTPException(
status_code=400,
detail="auth_mode must be 'token', 'password', or 'none'",
)
config["auth_mode"] = auth_mode
username = str(config.get("username", "")).strip()
password = str(config.get("password", "")).strip()
if auth_mode == "password" and (not username or not password):
raise HTTPException(
status_code=400,
detail="username and password are required when auth_mode is 'password'",
)
config["username"] = username
config["password"] = password
token_audience = str(config.get("token_audience", "")).strip()
config["token_audience"] = token_audience
iata = config.get("iata", "").upper().strip()
if not iata or not _IATA_RE.fullmatch(iata):
raise HTTPException(
status_code=400,
detail="IATA code is required and must be exactly 3 uppercase alphabetic characters",
)
config["iata"] = iata
topic_template = str(
config.get("topic_template", _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE)
).strip()
if not topic_template:
topic_template = _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE
config["topic_template"] = _normalize_community_topic_template(topic_template)
def _validate_bot_config(config: dict) -> None:
"""Validate bot config blob (syntax-check the code and supported signature)."""
code = config.get("code", "")
if not code or not code.strip():
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
try:
tree = ast.parse(code, filename="<bot_code>", mode="exec")
except SyntaxError as e:
raise HTTPException(
status_code=400,
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
) from None
bot_def = next(
(
node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "bot"
),
None,
)
if bot_def is None:
raise HTTPException(
status_code=400,
detail=(
"Bot code must define a callable bot() function. "
"Use the default bot template as a reference."
),
)
try:
parameters: list[inspect.Parameter] = []
positional_args = [
*((arg, inspect.Parameter.POSITIONAL_ONLY) for arg in bot_def.args.posonlyargs),
*((arg, inspect.Parameter.POSITIONAL_OR_KEYWORD) for arg in bot_def.args.args),
]
positional_defaults_start = len(positional_args) - len(bot_def.args.defaults)
sentinel_default = object()
for index, (arg, kind) in enumerate(positional_args):
has_default = index >= positional_defaults_start
parameters.append(
inspect.Parameter(
arg.arg,
kind=kind,
default=sentinel_default if has_default else inspect.Parameter.empty,
)
)
if bot_def.args.vararg is not None:
parameters.append(
inspect.Parameter(bot_def.args.vararg.arg, kind=inspect.Parameter.VAR_POSITIONAL)
)
for kwonly_arg, kw_default in zip(
bot_def.args.kwonlyargs, bot_def.args.kw_defaults, strict=True
):
parameters.append(
inspect.Parameter(
kwonly_arg.arg,
kind=inspect.Parameter.KEYWORD_ONLY,
default=(
sentinel_default if kw_default is not None else inspect.Parameter.empty
),
)
)
if bot_def.args.kwarg is not None:
parameters.append(
inspect.Parameter(bot_def.args.kwarg.arg, kind=inspect.Parameter.VAR_KEYWORD)
)
_analyze_bot_signature(inspect.Signature(parameters))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
def _validate_apprise_config(config: dict) -> None:
"""Validate apprise config blob."""
urls = config.get("urls", "")
if not urls or not urls.strip():
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob."""
url = config.get("url", "")
if not url:
raise HTTPException(status_code=400, detail="url is required for webhook")
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="url must start with http:// or https://")
method = config.get("method", "POST").upper()
if method not in ("POST", "PUT", "PATCH"):
raise HTTPException(status_code=400, detail="method must be POST, PUT, or PATCH")
headers = config.get("headers", {})
if not isinstance(headers, dict):
raise HTTPException(status_code=400, detail="headers must be a JSON object")
def _validate_sqs_config(config: dict) -> None:
"""Validate sqs config blob."""
queue_url = str(config.get("queue_url", "")).strip()
if not queue_url:
raise HTTPException(status_code=400, detail="queue_url is required for sqs")
if not queue_url.startswith(("https://", "http://")):
raise HTTPException(status_code=400, detail="queue_url must start with http:// or https://")
endpoint_url = str(config.get("endpoint_url", "")).strip()
if endpoint_url and not endpoint_url.startswith(("https://", "http://")):
raise HTTPException(
status_code=400,
detail="endpoint_url must start with http:// or https://",
)
access_key_id = str(config.get("access_key_id", "")).strip()
secret_access_key = str(config.get("secret_access_key", "")).strip()
session_token = str(config.get("session_token", "")).strip()
has_static_keypair = bool(access_key_id) and bool(secret_access_key)
has_partial_keypair = bool(access_key_id) != bool(secret_access_key)
if has_partial_keypair:
raise HTTPException(
status_code=400,
detail="access_key_id and secret_access_key must be set together for sqs",
)
if session_token and not has_static_keypair:
raise HTTPException(
status_code=400,
detail="session_token requires access_key_id and secret_access_key for sqs",
)
def _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 and sqs, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
raise HTTPException(
status_code=400,
detail="scope.messages must be 'all', 'none', or a filter object",
)
raw_packets = scope.get("raw_packets", "all")
if raw_packets not in ("all", "none"):
raise HTTPException(
status_code=400,
detail="scope.raw_packets must be 'all' or 'none'",
)
return {"messages": messages, "raw_packets": raw_packets}
@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")
normalized_config = _validate_and_normalize_config(body.type, body.config)
scope = _enforce_scope(body.type, body.scope)
cfg = await FanoutConfigRepository.create(
config_type=body.type,
name=body.name,
config=normalized_config,
scope=scope,
enabled=body.enabled,
)
# Start the module if enabled
if cfg["enabled"]:
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.scope is not None:
kwargs["scope"] = _enforce_scope(existing["type"], body.scope)
config_to_validate = body.config if body.config is not None else existing["config"]
kwargs["config"] = _validate_and_normalize_config(existing["type"], config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:
raise HTTPException(status_code=404, detail="Fanout config not found")
# Reload the module to pick up changes
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,27 +1,54 @@
import os
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
from app.config import settings
from app.radio import radio_manager
from app.repository import RawPacketRepository
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
router = APIRouter(tags=["health"])
class RadioDeviceInfoResponse(BaseModel):
model: str | None = None
firmware_build: str | None = None
firmware_version: str | None = None
max_contacts: int | None = None
max_channels: int | None = None
class AppInfoResponse(BaseModel):
version: str
commit_hash: str | None = None
class HealthResponse(BaseModel):
status: str
radio_connected: bool
radio_initializing: bool = False
radio_state: str = "disconnected"
connection_info: str | None
app_info: AppInfoResponse | None = None
radio_device_info: RadioDeviceInfoResponse | None = None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
mqtt_status: str | None = None
loopback_eligible: bool = False
fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False
def _clean_optional_str(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
app_build_info = get_app_build_info()
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
@@ -35,26 +62,63 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
except RuntimeError:
pass # Database not connected
# MQTT status
mqtt_status: str | None = None
# Fanout module statuses
fanout_statuses: dict[str, Any] = {}
try:
from app.mqtt import mqtt_publisher
from app.fanout.manager import fanout_manager
if mqtt_publisher._mqtt_configured():
mqtt_status = "connected" if mqtt_publisher.connected else "disconnected"
else:
mqtt_status = "disabled"
fanout_statuses = fanout_manager.get_statuses()
except Exception:
pass
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
if not radio_connected:
setup_complete = False
connection_desired = getattr(radio_manager, "connection_desired", True)
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
if not connection_desired:
radio_state = "paused"
elif radio_initializing:
radio_state = "initializing"
elif radio_connected:
radio_state = "connected"
elif is_reconnecting:
radio_state = "connecting"
else:
radio_state = "disconnected"
radio_device_info = None
device_info_loaded = getattr(radio_manager, "device_info_loaded", False)
if radio_connected and device_info_loaded:
radio_device_info = {
"model": _clean_optional_str(getattr(radio_manager, "device_model", None)),
"firmware_build": _clean_optional_str(getattr(radio_manager, "firmware_build", None)),
"firmware_version": _clean_optional_str(
getattr(radio_manager, "firmware_version", None)
),
"max_contacts": getattr(radio_manager, "max_contacts", None),
"max_channels": getattr(radio_manager, "max_channels", None),
}
return {
"status": "ok" if radio_connected else "degraded",
"status": "ok" if radio_connected and not radio_initializing else "degraded",
"radio_connected": radio_connected,
"radio_initializing": radio_initializing,
"radio_state": radio_state,
"connection_info": connection_info,
"app_info": {
"version": app_build_info.version,
"commit_hash": app_build_info.commit_hash,
},
"radio_device_info": radio_device_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"mqtt_status": mqtt_status,
"loopback_eligible": settings.loopback_eligible,
"fanout_statuses": fanout_statuses,
"bots_disabled": settings.disable_bots,
}

View File

@@ -1,116 +0,0 @@
"""WebSocket endpoint for loopback transport (browser-bridged radio connection)."""
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from starlette.websockets import WebSocketState
from app.config import settings
from app.loopback import LoopbackTransport
from app.radio import radio_manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.websocket("/ws/transport")
async def loopback_transport(websocket: WebSocket) -> None:
"""Bridge a browser-side serial/BLE connection to the backend MeshCore stack.
Protocol:
1. Client sends init JSON: {"type": "init", "mode": "serial"|"ble"}
2. Binary frames flow bidirectionally (raw bytes for BLE, framed for serial)
3. Either side can send {"type": "disconnect"} to tear down
"""
# Guard: reject if an explicit transport is configured via env vars
if not settings.loopback_eligible:
await websocket.accept()
await websocket.close(code=4003, reason="Explicit transport configured")
return
# Guard: reject if the radio is already connected (direct or another loopback)
if radio_manager.is_connected:
await websocket.accept()
await websocket.close(code=4004, reason="Radio already connected")
return
await websocket.accept()
transport: LoopbackTransport | None = None
setup_task: asyncio.Task | None = None
try:
# Wait for init message
init_raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
init_msg = json.loads(init_raw)
if init_msg.get("type") != "init" or init_msg.get("mode") not in ("serial", "ble"):
await websocket.close(code=4001, reason="Invalid init message")
return
mode = init_msg["mode"]
logger.info("Loopback init: mode=%s", mode)
# Create transport and MeshCore instance
transport = LoopbackTransport(websocket, mode)
from meshcore import MeshCore
mc = MeshCore(transport, auto_reconnect=False, max_reconnect_attempts=0)
await mc.connect()
if not mc.is_connected:
logger.warning("Loopback MeshCore failed to connect")
await websocket.close(code=4005, reason="MeshCore handshake failed")
return
connection_info = f"Loopback ({mode})"
radio_manager.connect_loopback(mc, connection_info)
# Run post-connect setup in background so the receive loop can run
setup_task = asyncio.create_task(radio_manager.post_connect_setup())
# Main receive loop
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
if "bytes" in message and message["bytes"]:
transport.handle_rx(message["bytes"])
elif "text" in message and message["text"]:
try:
text_msg = json.loads(message["text"])
if text_msg.get("type") == "disconnect":
logger.info("Loopback client requested disconnect")
break
except (json.JSONDecodeError, TypeError):
pass
except WebSocketDisconnect:
logger.info("Loopback WebSocket disconnected")
except asyncio.TimeoutError:
logger.warning("Loopback init timeout")
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.close(code=4002, reason="Init timeout")
except Exception as e:
logger.exception("Loopback error: %s", e)
finally:
if setup_task is not None:
setup_task.cancel()
try:
await setup_task
except (asyncio.CancelledError, Exception):
pass
await radio_manager.disconnect_loopback()
if websocket.client_state == WebSocketState.CONNECTED:
try:
await websocket.close()
except Exception:
pass

View File

@@ -1,21 +1,52 @@
import asyncio
import logging
import time
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from app.dependencies import require_connected
from app.event_handlers import track_pending_ack
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
from app.radio import radio_manager
from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository
from app.websocket import broadcast_event
from app.models import (
Message,
MessagesAroundResponse,
ResendChannelMessageResponse,
SendChannelMessageRequest,
SendDirectMessageRequest,
)
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
from app.services.message_send import (
resend_channel_message_record,
send_channel_message_to_channel,
send_direct_message_to_contact,
)
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error, broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/messages", tags=["messages"])
@router.get("/around/{message_id}", response_model=MessagesAroundResponse)
async def get_messages_around(
message_id: int,
type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"),
conversation_key: str | None = Query(default=None, description="Filter by conversation key"),
context: int = Query(default=100, ge=1, le=500, description="Number of messages before/after"),
) -> MessagesAroundResponse:
"""Get messages around a specific message for jump-to-message navigation."""
settings = await AppSettingsRepository.get()
blocked_keys = settings.blocked_keys or None
blocked_names = settings.blocked_names or None
messages, has_older, has_newer = await MessageRepository.get_around(
message_id=message_id,
msg_type=type,
conversation_key=conversation_key,
context_size=context,
blocked_keys=blocked_keys,
blocked_names=blocked_names,
)
return MessagesAroundResponse(messages=messages, has_older=has_older, has_newer=has_newer)
@router.get("", response_model=list[Message])
async def list_messages(
limit: int = Query(default=100, ge=1, le=1000),
@@ -28,8 +59,18 @@ async def list_messages(
default=None, description="Cursor: received_at of last seen message"
),
before_id: int | None = Query(default=None, description="Cursor: id of last seen message"),
after: int | None = Query(
default=None, description="Forward cursor: received_at of last seen message"
),
after_id: int | None = Query(
default=None, description="Forward cursor: id of last seen message"
),
q: str | None = Query(default=None, description="Full-text search query"),
) -> list[Message]:
"""List messages from the database."""
settings = await AppSettingsRepository.get()
blocked_keys = settings.blocked_keys or None
blocked_names = settings.blocked_names or None
return await MessageRepository.get_all(
limit=limit,
offset=offset,
@@ -37,6 +78,11 @@ async def list_messages(
conversation_key=conversation_key,
before=before,
before_id=before_id,
after=after,
after_id=after_id,
q=q,
blocked_keys=blocked_keys,
blocked_names=blocked_names,
)
@@ -63,100 +109,27 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
raise HTTPException(
status_code=404, detail=f"Contact not found in database: {request.destination}"
)
# Always add/update the contact on radio before sending.
# The library cache (get_contact_by_key_prefix) can be stale after radio reboot,
# so we can't rely on it to know if the firmware has the contact.
# add_contact is idempotent - updates if exists, adds if not.
contact_data = db_contact.to_radio_dict()
async with radio_manager.radio_operation("send_direct_message") as mc:
logger.debug("Ensuring contact %s is on radio before sending", db_contact.public_key[:12])
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
# Continue anyway - might still work if contact exists
# Get the contact from the library cache (may have updated info like path)
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
if not contact:
contact = contact_data
logger.info("Sending direct message to %s", db_contact.public_key[:12])
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
# and the database. This ensures consistency for deduplication.
now = int(time.time())
result = await mc.commands.send_msg(
dst=contact,
msg=request.text,
timestamp=now,
)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
# Store outgoing message
message_id = await MessageRepository.create(
msg_type="PRIV",
text=request.text,
conversation_key=db_contact.public_key.lower(),
sender_timestamp=now,
received_at=now,
outgoing=True,
)
if message_id is None:
if len(db_contact.public_key) < 64:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
status_code=409,
detail="Cannot send to an unresolved prefix-only contact until a full key is known",
)
# Update last_contacted for the contact
await ContactRepository.update_last_contacted(db_contact.public_key.lower(), now)
# Track the expected ACK for this message
expected_ack = result.payload.get("expected_ack")
suggested_timeout: int = result.payload.get("suggested_timeout", 10000) # default 10s
if expected_ack:
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
track_pending_ack(ack_code, message_id, suggested_timeout)
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
message = Message(
id=message_id,
type="PRIV",
conversation_key=db_contact.public_key.lower(),
return await send_direct_message_to_contact(
contact=db_contact,
text=request.text,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=0,
radio_manager=radio_manager,
broadcast_fn=broadcast_event,
track_pending_ack_fn=track_pending_ack,
now_fn=time.time,
message_repository=MessageRepository,
contact_repository=ContactRepository,
)
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
broadcast_event("message", message.model_dump())
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=None,
sender_key=db_contact.public_key.lower(),
message_text=request.text,
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=now,
path=None,
is_outgoing=True,
)
)
return message
# Temporary radio slot used for sending channel messages
# Preferred first radio slot used for sending channel messages.
# The send service may reuse/load other app-managed slots depending on transport
# and session cache state.
TEMP_RADIO_SLOT = 0
@@ -166,7 +139,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
require_connected()
# Get channel info from our database
from app.decoder import calculate_channel_hash
from app.repository import ChannelRepository
db_channel = await ChannelRepository.get_by_key(request.channel_key)
@@ -183,135 +155,32 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
status_code=400, detail=f"Invalid channel key format: {request.channel_key}"
) from None
expected_hash = calculate_channel_hash(key_bytes)
logger.info(
"Sending to channel %s (%s) via radio slot %d, key hash: %s",
request.channel_key,
db_channel.name,
TEMP_RADIO_SLOT,
expected_hash,
return await send_channel_message_to_channel(
channel=db_channel,
channel_key_upper=request.channel_key.upper(),
key_bytes=key_bytes,
text=request.text,
radio_manager=radio_manager,
broadcast_fn=broadcast_event,
error_broadcast_fn=broadcast_error,
now_fn=time.time,
temp_radio_slot=TEMP_RADIO_SLOT,
message_repository=MessageRepository,
)
channel_key_upper = request.channel_key.upper()
message_id: int | None = None
now: int | None = None
radio_name: str = ""
text_with_sender: str = request.text
async with radio_manager.radio_operation("send_channel_message") as mc:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
# Load the channel to a temporary radio slot before sending
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
logger.warning(
"Failed to set channel on radio slot %d before sending: %s",
TEMP_RADIO_SLOT,
set_result.payload,
)
raise HTTPException(
status_code=500,
detail="Failed to configure channel on radio before sending message",
)
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
# and the database. This ensures the echo's timestamp matches our stored message
# for proper deduplication.
now = int(time.time())
timestamp_bytes = now.to_bytes(4, "little")
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=request.text,
timestamp=timestamp_bytes,
)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
# Store outgoing immediately after send to avoid a race where
# our own echo lands before persistence.
message_id = await MessageRepository.create(
msg_type="CHAN",
text=text_with_sender,
conversation_key=channel_key_upper,
sender_timestamp=now,
received_at=now,
outgoing=True,
)
if message_id is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
# Broadcast immediately so all connected clients see the message promptly.
# This ensures the message exists in frontend state when echo-driven
# `message_acked` events arrive.
broadcast_event(
"message",
Message(
id=message_id,
type="CHAN",
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=0,
).model_dump(),
)
if message_id is None or now is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
acked_count, paths = await MessageRepository.get_ack_and_paths(message_id)
message = Message(
id=message_id,
type="CHAN",
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=acked_count,
paths=paths,
)
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=radio_name or None,
sender_key=None,
message_text=request.text,
is_dm=False,
channel_key=channel_key_upper,
channel_name=db_channel.name,
sender_timestamp=now,
path=None,
is_outgoing=True,
)
)
return message
RESEND_WINDOW_SECONDS = 30
@router.post("/channel/{message_id}/resend")
@router.post(
"/channel/{message_id}/resend",
response_model=ResendChannelMessageResponse,
response_model_exclude_none=True,
)
async def resend_channel_message(
message_id: int,
new_timestamp: bool = Query(default=False),
) -> dict:
) -> ResendChannelMessageResponse:
"""Resend a channel message.
When new_timestamp=False (default): byte-perfect resend using the original timestamp.
@@ -347,89 +216,14 @@ async def resend_channel_message(
if not db_channel:
raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
# Choose timestamp: original for byte-perfect, fresh for new-timestamp
if new_timestamp:
now = int(time.time())
timestamp_bytes = now.to_bytes(4, "little")
else:
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
try:
key_bytes = bytes.fromhex(msg.conversation_key)
except ValueError:
raise HTTPException(
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
) from None
async with radio_manager.radio_operation("resend_channel_message") as mc:
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_to_send = msg.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail="Failed to configure channel on radio before resending",
)
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=text_to_send,
timestamp=timestamp_bytes,
)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to resend message: {result.payload}"
)
# For new-timestamp resend, create a new message row and broadcast it
if new_timestamp:
new_msg_id = await MessageRepository.create(
msg_type="CHAN",
text=msg.text,
conversation_key=msg.conversation_key,
sender_timestamp=now,
received_at=now,
outgoing=True,
)
if new_msg_id is None:
# Timestamp-second collision (same text+channel within the same second).
# The radio already transmitted, so log and return the original ID rather
# than surfacing a 500 for a message that was successfully sent over the air.
logger.warning(
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
message_id,
)
return {"status": "ok", "message_id": message_id}
broadcast_event(
"message",
Message(
id=new_msg_id,
type="CHAN",
conversation_key=msg.conversation_key,
text=msg.text,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=0,
).model_dump(),
)
logger.info(
"Resent channel message %d as new message %d to %s",
message_id,
new_msg_id,
db_channel.name,
)
return {"status": "ok", "message_id": new_msg_id}
logger.info("Resent channel message %d to %s", message_id, db_channel.name)
return {"status": "ok", "message_id": message_id}
return await resend_channel_message_record(
message=msg,
channel=db_channel,
new_timestamp=new_timestamp,
radio_manager=radio_manager,
broadcast_fn=broadcast_event,
error_broadcast_fn=broadcast_error,
now_fn=time.time,
temp_radio_slot=TEMP_RADIO_SLOT,
message_repository=MessageRepository,
)

View File

@@ -3,7 +3,7 @@ from hashlib import sha256
from sqlite3 import OperationalError
import aiosqlite
from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
from pydantic import BaseModel, Field
from app.database import db
@@ -40,6 +40,10 @@ class DecryptResult(BaseModel):
message: str
def _bad_request(detail: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
async def _run_historical_channel_decryption(
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
) -> None:
@@ -71,7 +75,8 @@ async def _run_historical_channel_decryption(
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
trigger_bot=False, # Historical decryption should not trigger bot
path_len=packet_info.path_length if packet_info else None,
realtime=False, # Historical decryption should not trigger fanout
)
if msg_id is not None:
@@ -99,7 +104,7 @@ async def get_undecrypted_count() -> dict:
@router.post("/decrypt/historical", response_model=DecryptResult)
async def decrypt_historical_packets(
request: DecryptRequest, background_tasks: BackgroundTasks
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
) -> DecryptResult:
"""
Attempt to decrypt historical packets with the provided key.
@@ -111,27 +116,15 @@ async def decrypt_historical_packets(
try:
channel_key_bytes = bytes.fromhex(request.channel_key)
if len(channel_key_bytes) != 16:
return DecryptResult(
started=False,
total_packets=0,
message="Channel key must be 16 bytes (32 hex chars)",
)
raise _bad_request("Channel key must be 16 bytes (32 hex chars)")
channel_key_hex = request.channel_key.upper()
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for channel key",
)
raise _bad_request("Invalid hex string for channel key") from None
elif request.channel_name:
channel_key_bytes = sha256(request.channel_name.encode("utf-8")).digest()[:16]
channel_key_hex = channel_key_bytes.hex().upper()
else:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide channel_key or channel_name",
)
raise _bad_request("Must provide channel_key or channel_name")
# Get count and lookup channel name for display
count = await RawPacketRepository.get_undecrypted_count()
@@ -147,6 +140,7 @@ async def decrypt_historical_packets(
background_tasks.add_task(
_run_historical_channel_decryption, channel_key_bytes, channel_key_hex, display_name
)
response.status_code = status.HTTP_202_ACCEPTED
return DecryptResult(
started=True,
@@ -157,48 +151,24 @@ async def decrypt_historical_packets(
elif request.key_type == "contact":
# DM decryption
if not request.private_key:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide private_key for contact decryption",
)
raise _bad_request("Must provide private_key for contact decryption")
if not request.contact_public_key:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide contact_public_key for contact decryption",
)
raise _bad_request("Must provide contact_public_key for contact decryption")
try:
private_key_bytes = bytes.fromhex(request.private_key)
if len(private_key_bytes) != 64:
return DecryptResult(
started=False,
total_packets=0,
message="Private key must be 64 bytes (128 hex chars)",
)
raise _bad_request("Private key must be 64 bytes (128 hex chars)")
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for private key",
)
raise _bad_request("Invalid hex string for private key") from None
try:
contact_public_key_bytes = bytes.fromhex(request.contact_public_key)
if len(contact_public_key_bytes) != 32:
return DecryptResult(
started=False,
total_packets=0,
message="Contact public key must be 32 bytes (64 hex chars)",
)
raise _bad_request("Contact public key must be 32 bytes (64 hex chars)")
contact_public_key_hex = request.contact_public_key.lower()
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for contact public key",
)
raise _bad_request("Invalid hex string for contact public key") from None
packets = await RawPacketRepository.get_undecrypted_text_messages()
count = len(packets)
@@ -222,6 +192,7 @@ async def decrypt_historical_packets(
contact_public_key_hex,
display_name,
)
response.status_code = status.HTTP_202_ACCEPTED
return DecryptResult(
started=True,
@@ -229,11 +200,7 @@ async def decrypt_historical_packets(
message=f"Started DM decryption of {count} TEXT_MESSAGE packets in background",
)
return DecryptResult(
started=False,
total_packets=0,
message="key_type must be 'channel' or 'contact'",
)
raise _bad_request("key_type must be 'channel' or 'contact'")
class MaintenanceRequest(BaseModel):

View File

@@ -1,17 +1,60 @@
import asyncio
import logging
import random
import time
from typing import Literal, TypeAlias
from fastapi import APIRouter, HTTPException
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.radio import radio_manager
from app.models import (
ContactUpsert,
RadioDiscoveryRequest,
RadioDiscoveryResponse,
RadioDiscoveryResult,
)
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
from app.repository import ContactRepository
from app.services.radio_commands import (
KeystoreRefreshError,
PathHashModeUnsupportedError,
RadioCommandRejectedError,
apply_radio_config_update,
import_private_key_and_refresh_keystore,
)
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_event, broadcast_health
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"]
RadioAdvertMode = Literal["flood", "zero_hop"]
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
DISCOVERY_WINDOW_SECONDS = 8.0
_DISCOVERY_TARGET_BITS = {
"repeaters": 1 << 2,
"sensors": 1 << 4,
"all": (1 << 2) | (1 << 4),
}
_DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
2: "repeater",
4: "sensor",
}
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool:
return await radio_manager.reconnect_and_prepare(
broadcast_on_success=broadcast_on_success,
)
class RadioSettings(BaseModel):
freq: float = Field(description="Frequency in MHz")
@@ -28,6 +71,20 @@ class RadioConfigResponse(BaseModel):
tx_power: int = Field(description="Transmit power in dBm")
max_tx_power: int = Field(description="Maximum transmit power in dBm")
radio: RadioSettings
path_hash_mode: int = Field(
default=0, description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)"
)
path_hash_mode_supported: bool = Field(
default=False, description="Whether firmware supports path hash mode setting"
)
advert_location_source: AdvertLocationSource = Field(
default="current",
description="Whether adverts include the node's current location state",
)
multi_acks_enabled: bool = Field(
default=False,
description="Whether the radio sends an extra direct ACK transmission",
)
class RadioConfigUpdate(BaseModel):
@@ -36,12 +93,115 @@ class RadioConfigUpdate(BaseModel):
lon: float | None = None
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
radio: RadioSettings | None = None
path_hash_mode: int | None = Field(
default=None,
ge=0,
le=2,
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)",
)
advert_location_source: AdvertLocationSource | None = Field(
default=None,
description="Whether adverts include the node's current location state",
)
multi_acks_enabled: bool | None = Field(
default=None,
description="Whether the radio sends an extra direct ACK transmission",
)
class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
class RadioAdvertiseRequest(BaseModel):
mode: RadioAdvertMode = Field(
default="flood",
description="Advertisement mode: flood through repeaters or zero-hop local only",
)
def _monotonic() -> float:
return time.monotonic()
def _better_signal(first: float | None, second: float | None) -> float | None:
if first is None:
return second
if second is None:
return first
return second if second > first else first
def _coerce_float(value: object) -> float | None:
if isinstance(value, (int, float)):
return float(value)
return None
def _coerce_int(value: object) -> int | None:
if isinstance(value, int):
return value
return None
def _merge_discovery_result(
existing: RadioDiscoveryResult | None, event_payload: dict[str, object]
) -> RadioDiscoveryResult | None:
public_key = event_payload.get("pubkey")
node_type_code = event_payload.get("node_type")
if not isinstance(public_key, str) or not public_key:
return existing
if not isinstance(node_type_code, int):
return existing
node_type = _DISCOVERY_NODE_TYPES.get(node_type_code)
if node_type is None:
return existing
if existing is None:
return RadioDiscoveryResult(
public_key=public_key,
node_type=node_type,
heard_count=1,
local_snr=_coerce_float(event_payload.get("SNR")),
local_rssi=_coerce_int(event_payload.get("RSSI")),
remote_snr=_coerce_float(event_payload.get("SNR_in")),
)
existing.heard_count += 1
existing.local_snr = _better_signal(existing.local_snr, _coerce_float(event_payload.get("SNR")))
current_rssi = _coerce_int(event_payload.get("RSSI"))
if existing.local_rssi is None or (
current_rssi is not None and current_rssi > existing.local_rssi
):
existing.local_rssi = current_rssi
existing.remote_snr = _better_signal(
existing.remote_snr,
_coerce_float(event_payload.get("SNR_in")),
)
return existing
async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -> None:
now = int(time.time())
for result in results:
existing = await ContactRepository.get_by_key(result.public_key)
if existing is not None:
continue
contact = ContactUpsert(
public_key=result.public_key,
type=2 if result.node_type == "repeater" else 4,
last_seen=now,
first_seen=now,
on_radio=False,
)
await ContactRepository.upsert(contact)
created = await ContactRepository.get_by_key(result.public_key)
if created is not None:
broadcast_event("contact", created.model_dump())
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
@@ -51,6 +211,9 @@ async def get_radio_config() -> RadioConfigResponse:
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
adv_loc_policy = info.get("adv_loc_policy", 1)
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
@@ -64,6 +227,10 @@ async def get_radio_config() -> RadioConfigResponse:
sf=info.get("radio_sf", 0),
cr=info.get("radio_cr", 0),
),
path_hash_mode=radio_manager.path_hash_mode,
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
advert_location_source=advert_location_source,
multi_acks_enabled=bool(info.get("multi_acks", 0)),
)
@@ -73,43 +240,18 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
require_connected()
async with radio_manager.radio_operation("update_radio_config") as mc:
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
if update.lat is not None or update.lon is not None:
current_info = mc.self_info
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
logger.info("Setting radio coordinates to %f, %f", lat, lon)
await mc.commands.set_coords(lat=lat, lon=lon)
if update.tx_power is not None:
logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=update.tx_power)
if update.radio is not None:
logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
update.radio.freq,
update.radio.bw,
update.radio.sf,
update.radio.cr,
try:
await apply_radio_config_update(
mc,
update,
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
set_path_hash_mode=lambda mode: setattr(radio_manager, "path_hash_mode", mode),
sync_radio_time_fn=sync_radio_time,
)
await mc.commands.set_radio(
freq=update.radio.freq,
bw=update.radio.bw,
sf=update.radio.sf,
cr=update.radio.cr,
)
# Sync time with system clock
await sync_radio_time(mc)
# Re-fetch self_info so the response reflects the changes we just made.
# Commands like set_name() write to flash but don't update the cached
# self_info — send_appstart() triggers a fresh SELF_INFO from the radio.
await mc.commands.send_appstart()
except PathHashModeUnsupportedError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RadioCommandRejectedError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return await get_radio_config()
@@ -126,59 +268,114 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
logger.info("Importing private key")
async with radio_manager.radio_operation("import_private_key") as mc:
result = await mc.commands.import_private_key(key_bytes)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to import private key: {result.payload}"
)
# Re-export from radio so the server-side keystore uses the new key
# for DM decryption immediately, rather than waiting for reconnect.
from app.keystore import export_and_store_private_key
keystore_refreshed = await export_and_store_private_key(mc)
if not keystore_refreshed:
logger.warning("Keystore refresh failed after import, retrying once")
keystore_refreshed = await export_and_store_private_key(mc)
if not keystore_refreshed:
raise HTTPException(
status_code=500,
detail=(
"Private key imported on radio, but server-side keystore "
"refresh failed. Reconnect to apply the new key for DM decryption."
),
)
try:
await import_private_key_and_refresh_keystore(
mc,
key_bytes,
export_and_store_private_key_fn=export_and_store_private_key,
)
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "ok"}
@router.post("/advertise")
async def send_advertisement() -> dict:
"""Send a flood advertisement to announce presence on the mesh.
async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> dict:
"""Send an advertisement to announce presence on the mesh.
Manual advertisement requests always send immediately, updating the
last_advert_time which affects when the next periodic/startup advert
can occur.
Manual advertisement requests always send immediately. Flood adverts update
the shared flood-advert timing state used by periodic/startup advertising;
zero-hop adverts currently do not.
Returns:
status: "ok" if sent successfully
"""
require_connected()
mode: RadioAdvertMode = request.mode if request is not None else "flood"
logger.info("Sending flood advertisement")
logger.info("Sending %s advertisement", mode.replace("_", "-"))
async with radio_manager.radio_operation("manual_advertisement") as mc:
success = await do_send_advertisement(mc, force=True)
success = await do_send_advertisement(mc, force=True, mode=mode)
if not success:
raise HTTPException(status_code=500, detail="Failed to send advertisement")
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
return {"status": "ok"}
@router.post("/discover", response_model=RadioDiscoveryResponse)
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
"""Run a short node-discovery sweep from the local radio."""
require_connected()
target_bits = _DISCOVERY_TARGET_BITS[request.target]
tag = random.randint(1, 0xFFFFFFFF)
tag_hex = tag.to_bytes(4, "little", signed=False).hex()
events: asyncio.Queue = asyncio.Queue()
async with radio_manager.radio_operation(
"discover_mesh",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
subscription = mc.subscribe(
EventType.DISCOVER_RESPONSE,
lambda event: events.put_nowait(event),
{"tag": tag_hex},
)
try:
send_result = await mc.commands.send_node_discover_req(
target_bits,
prefix_only=False,
tag=tag,
)
if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to start mesh discovery")
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
results_by_key: dict[str, RadioDiscoveryResult] = {}
while True:
remaining = deadline - _monotonic()
if remaining <= 0:
break
try:
event = await asyncio.wait_for(events.get(), timeout=remaining)
except asyncio.TimeoutError:
break
merged = _merge_discovery_result(
results_by_key.get(event.payload.get("pubkey")),
event.payload,
)
if merged is not None:
results_by_key[merged.public_key] = merged
finally:
subscription.unsubscribe()
results = sorted(
results_by_key.values(),
key=lambda item: (
item.node_type,
-(item.local_snr if item.local_snr is not None else -999.0),
item.public_key,
),
)
await _persist_new_discovery_contacts(results)
return RadioDiscoveryResponse(
target=request.target,
duration_seconds=DISCOVERY_WINDOW_SECONDS,
results=results,
)
async def _attempt_reconnect() -> dict:
"""Shared reconnection logic for reboot and reconnect endpoints."""
radio_manager.resume_connection()
if radio_manager.is_reconnecting:
return {
"status": "pending",
@@ -186,14 +383,8 @@ async def _attempt_reconnect() -> dict:
"connected": False,
}
success = await radio_manager.reconnect()
if not success:
raise HTTPException(
status_code=503, detail="Failed to reconnect. Check radio connection and power."
)
try:
await radio_manager.post_connect_setup()
success = await _reconnect_and_prepare(broadcast_on_success=True)
except Exception as e:
logger.exception("Post-connect setup failed after reconnect")
raise HTTPException(
@@ -201,9 +392,28 @@ async def _attempt_reconnect() -> dict:
detail=f"Radio connected but setup failed: {e}",
) from e
if not success:
raise HTTPException(
status_code=503, detail="Failed to reconnect. Check radio connection and power."
)
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
@router.post("/disconnect")
async def disconnect_radio() -> dict:
"""Disconnect from the radio and pause automatic reconnect attempts."""
logger.info("Manual radio disconnect requested")
await radio_manager.pause_connection()
broadcast_health(False, radio_manager.connection_info)
return {
"status": "ok",
"message": "Disconnected. Automatic reconnect is paused.",
"connected": False,
"paused": True,
}
@router.post("/reboot")
async def reboot_radio() -> dict:
"""Reboot the radio, or reconnect if not currently connected.
@@ -238,8 +448,11 @@ async def reconnect_radio() -> dict:
logger.info("Radio connected but setup incomplete, retrying setup")
try:
await radio_manager.post_connect_setup()
if not await _prepare_connected(broadcast_on_success=True):
raise HTTPException(status_code=503, detail="Radio connection is paused")
return {"status": "ok", "message": "Setup completed", "connected": True}
except HTTPException:
raise
except Exception as e:
logger.exception("Post-connect setup failed")
raise HTTPException(

View File

@@ -6,8 +6,13 @@ import time
from fastapi import APIRouter
from app.models import UnreadCounts
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository, MessageRepository
from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
)
from app.services.radio_runtime import radio_runtime as radio_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/read-state", tags=["read-state"])
@@ -26,7 +31,12 @@ async def get_unreads() -> UnreadCounts:
mc = radio_manager.meshcore
if mc and mc.self_info:
name = mc.self_info.get("name") or None
data = await MessageRepository.get_unread_counts(name)
settings = await AppSettingsRepository.get()
blocked_keys = settings.blocked_keys or None
blocked_names = settings.blocked_names or None
data = await MessageRepository.get_unread_counts(
name, blocked_keys=blocked_keys, blocked_names=blocked_names
)
return UnreadCounts(**data)

View File

@@ -1,6 +1,5 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException
@@ -21,13 +20,22 @@ from app.models import (
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterNeighborsResponse,
RepeaterNodeInfoResponse,
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
from app.routers.server_control import (
_monotonic,
batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection,
require_server_capable_contact,
send_contact_cli_command,
)
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
from meshcore.events import Event
@@ -42,26 +50,11 @@ ACL_PERMISSION_NAMES = {
3: "Admin",
}
router = APIRouter(prefix="/contacts", tags=["repeaters"])
# Delay between repeater radio operations to allow key exchange and path establishment
REPEATER_OP_DELAY_SECONDS = 2.0
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()
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
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
return extract_response_text(event)
async def _fetch_repeater_response(
@@ -69,21 +62,6 @@ async def _fetch_repeater_response(
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:
@@ -91,13 +69,12 @@ async def _fetch_repeater_response(
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as e:
logger.debug("get_msg() exception: %s", e)
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
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
@@ -111,8 +88,6 @@ async def _fetch_repeater_response(
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,
@@ -122,7 +97,6 @@ async def _fetch_repeater_response(
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"),
@@ -135,31 +109,14 @@ async def _fetch_repeater_response(
return None
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> None:
"""Prepare connection to a repeater by adding to radio and logging in.
Args:
mc: MeshCore instance
contact: The repeater contact
password: Password for login (empty string for no password)
Raises:
HTTPException: If login fails
"""
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
# Send login with password
logger.info("Sending login to repeater %s", contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
raise HTTPException(status_code=401, detail=f"Login failed: {login_result.payload}")
# Wait for key exchange to complete before sending requests
logger.debug("Waiting %.1fs for key exchange to complete", REPEATER_OP_DELAY_SECONDS)
await asyncio.sleep(REPEATER_OP_DELAY_SECONDS)
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
return await prepare_authenticated_contact_connection(
mc,
contact,
password,
label="repeater",
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
def _require_repeater(contact: Contact) -> None:
@@ -179,7 +136,7 @@ def _require_repeater(contact: Contact) -> None:
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Log in to a repeater. Adds contact to radio, sends login, waits for key exchange."""
"""Attempt repeater login and report whether auth was confirmed."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
@@ -189,9 +146,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await prepare_repeater_connection(mc, contact, request.password)
return RepeaterLoginResponse(status="ok")
return await prepare_repeater_connection(mc, contact, request.password)
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
@@ -334,48 +289,32 @@ async def _batch_cli_fetch(
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a repeater and collect responses.
return await batch_cli_fetch(contact, operation_name, commands)
Opens a radio operation with polling paused and auto-fetch suspended (since
we call get_msg() directly via _fetch_repeater_response), adds the contact
to the radio for routing, then sends each command sequentially with a 1-second
gap between them.
Returns a dict mapping field names to response strings (or None on timeout).
"""
results: dict[str, str | None] = {field: None for _, field in commands}
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
"""Fetch repeater identity/location info via a small CLI batch."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
for i, (cmd, field) in enumerate(commands):
if i > 0:
await asyncio.sleep(1.0)
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR:
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
continue
response_event = await _fetch_repeater_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is not None:
results[field] = _extract_response_text(response_event)
else:
logger.warning("No response for command '%s' (%s)", cmd, field)
return results
results = await _batch_cli_fetch(
contact,
"repeater_node_info",
[
("get name", "name"),
("get lat", "lat"),
("get lon", "lon"),
("clock", "clock_utc"),
],
)
return RepeaterNodeInfoResponse(**results)
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
"""Fetch radio settings from a repeater via batch CLI commands."""
"""Fetch radio settings from a repeater via radio/config CLI commands."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
@@ -390,10 +329,6 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
("get af", "airtime_factor"),
("get repeat", "repeat_enabled"),
("get flood.max", "flood_max"),
("get name", "name"),
("get lat", "lat"),
("get lon", "lon"),
("clock", "clock_utc"),
],
)
return RepeaterRadioSettingsResponse(**results)
@@ -439,72 +374,13 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
@router.post("/{public_key}/command", response_model=CommandResponse)
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
"""Send a CLI command to a repeater.
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
"""
"""Send a CLI command to a repeater or room server."""
require_connected()
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"send_repeater_command",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
# Send the command
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}"
)
# Wait for response using validated fetch loop
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
if response_event is None:
logger.warning(
"No response from repeater %s for command: %s",
contact.public_key[:12],
request.command,
)
return CommandResponse(
command=request.command,
response="(no response - command may have been processed)",
)
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
response_text = _extract_response_text(response_event)
sender_timestamp = response_event.payload.get(
"sender_timestamp",
response_event.payload.get("timestamp"),
)
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
return CommandResponse(
command=request.command,
response=response_text,
sender_timestamp=sender_timestamp,
)
require_server_capable_contact(contact)
return await send_contact_cli_command(
contact,
request.command,
operation_name="send_repeater_command",
)

145
app/routers/rooms.py Normal file
View File

@@ -0,0 +1,145 @@
from fastapi import APIRouter, HTTPException
from app.dependencies import require_connected
from app.models import (
CONTACT_TYPE_ROOM,
AclEntry,
LppSensor,
RepeaterAclResponse,
RepeaterLoginRequest,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterStatusResponse,
)
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
prepare_authenticated_contact_connection,
require_server_capable_contact,
)
from app.services.radio_runtime import radio_runtime as radio_manager
router = APIRouter(prefix="/contacts", tags=["rooms"])
def _require_room(contact) -> None:
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Attempt room-server login and report whether auth was confirmed."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_login",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
return await prepare_authenticated_contact_connection(
mc,
contact,
request.password,
label="room server",
)
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
async def room_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_status", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
if status is None:
raise HTTPException(status_code=504, detail="No status response from room server")
return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
last_rssi_dbm=status.get("last_rssi", 0),
last_snr_db=status.get("last_snr", 0.0),
packets_received=status.get("nb_recv", 0),
packets_sent=status.get("nb_sent", 0),
airtime_seconds=status.get("airtime", 0),
rx_airtime_seconds=status.get("rx_airtime", 0),
uptime_seconds=status.get("uptime", 0),
sent_flood=status.get("sent_flood", 0),
sent_direct=status.get("sent_direct", 0),
recv_flood=status.get("recv_flood", 0),
recv_direct=status.get("recv_direct", 0),
flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0),
)
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP telemetry from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
telemetry = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from room server")
sensors = [
LppSensor(
channel=entry.get("channel", 0),
type_name=str(entry.get("type", "unknown")),
value=entry.get("value", 0),
)
for entry in telemetry
]
return RepeaterLppTelemetryResponse(sensors=sensors)
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
async def room_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL entries from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_acl", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
acl_entries = []
if acl_data and isinstance(acl_data, list):
from app.repository import ContactRepository
from app.routers.repeaters import ACL_PERMISSION_NAMES
for entry in acl_data:
pubkey_prefix = entry.get("key", "")
perm = entry.get("perm", 0)
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
acl_entries.append(
AclEntry(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
permission=perm,
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
)
)
return RepeaterAclResponse(acl=acl_entries)

View File

@@ -0,0 +1,317 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from fastapi import HTTPException
from meshcore import EventType
from app.models import (
CONTACT_TYPE_REPEATER,
CONTACT_TYPE_ROOM,
CommandResponse,
Contact,
RepeaterLoginResponse,
)
from app.routers.contacts import _ensure_on_radio
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
def _monotonic() -> float:
"""Wrapper around time.monotonic() for testability."""
return time.monotonic()
def get_server_contact_label(contact: Contact) -> str:
"""Return a user-facing label for server-capable contacts."""
if contact.type == CONTACT_TYPE_REPEATER:
return "repeater"
if contact.type == CONTACT_TYPE_ROOM:
return "room server"
return "server"
def require_server_capable_contact(
contact: Contact,
*,
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
) -> None:
"""Raise 400 if the contact does not support server control/login features."""
if contact.type not in allowed_types:
expected = ", ".join(str(value) for value in allowed_types)
raise HTTPException(
status_code=400,
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
)
def _login_rejected_message(label: str) -> str:
return (
f"The {label} replied but did not confirm this login. "
f"Existing access may still allow some {label} operations, but privileged actions may fail."
)
def _login_send_failed_message(label: str) -> str:
return (
f"The login request could not be sent to the {label}. "
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
)
def _login_timeout_message(label: str) -> str:
return (
f"No login confirmation was heard from the {label}. "
"That can mean the password was wrong or the reply was missed in transit. "
"The control panel is still available; try logging in again if authenticated actions fail."
)
def extract_response_text(event) -> str:
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
text = event.payload.get("text", str(event.payload))
if text.startswith("> "):
text = text[2:]
return text
async def fetch_contact_cli_response(
mc,
target_pubkey_prefix: str,
timeout: float = 20.0,
) -> "Event | None":
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
deadline = _monotonic() + timeout
while _monotonic() < deadline:
try:
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
await asyncio.sleep(1.0)
continue
if result.type == EventType.NO_MORE_MSGS:
await asyncio.sleep(1.0)
continue
if result.type == EventType.ERROR:
logger.debug("get_msg() error: %s", result.payload)
await asyncio.sleep(1.0)
continue
if result.type == EventType.CONTACT_MSG_RECV:
msg_prefix = result.payload.get("pubkey_prefix", "")
txt_type = result.payload.get("txt_type", 0)
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
logger.debug(
"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:
logger.debug(
"Skipping channel message (channel_idx=%s) during CLI fetch",
result.payload.get("channel_idx"),
)
continue
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
return None
async def prepare_authenticated_contact_connection(
mc,
contact: Contact,
password: str,
*,
label: str | None = None,
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
) -> RepeaterLoginResponse:
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
pubkey_prefix = contact.public_key[:12].lower()
contact_label = label or get_server_contact_label(contact)
loop = asyncio.get_running_loop()
login_future = loop.create_future()
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
if login_future.done():
return
login_future.set_result(
RepeaterLoginResponse(
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
authenticated=event_type == EventType.LOGIN_SUCCESS,
message=message,
)
)
success_subscription = mc.subscribe(
EventType.LOGIN_SUCCESS,
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
failed_subscription = mc.subscribe(
EventType.LOGIN_FAILED,
lambda _event: _resolve_login(
EventType.LOGIN_FAILED,
_login_rejected_message(contact_label),
),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
try:
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
)
try:
return await asyncio.wait_for(
login_future,
timeout=response_timeout,
)
except asyncio.TimeoutError:
logger.warning(
"No login response from %s %s within %.1fs",
contact_label,
contact.public_key[:12],
response_timeout,
)
return RepeaterLoginResponse(
status="timeout",
authenticated=False,
message=_login_timeout_message(contact_label),
)
except HTTPException as exc:
logger.warning(
"%s login setup failed for %s: %s",
contact_label.capitalize(),
contact.public_key[:12],
exc.detail,
)
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
)
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
async def batch_cli_fetch(
contact: Contact,
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
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 index, (cmd, field) in enumerate(commands):
if index > 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_contact_cli_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is not None:
results[field] = extract_response_text(response_event)
else:
logger.warning("No response for command '%s' (%s)", cmd, field)
return results
async def send_contact_cli_command(
contact: Contact,
command: str,
*,
operation_name: str,
) -> CommandResponse:
"""Send a CLI command to a server-capable contact and return the text response."""
label = get_server_contact_label(contact)
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
send_result = await mc.commands.send_cmd(contact.public_key, command)
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}"
)
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
if response_event is None:
logger.warning(
"No response from %s %s for command: %s",
label,
contact.public_key[:12],
command,
)
return CommandResponse(
command=command,
response="(no response - command may have been processed)",
)
response_text = extract_response_text(response_event)
sender_timestamp = response_event.payload.get(
"sender_timestamp",
response_event.payload.get("timestamp"),
)
logger.info(
"Received response from %s %s: %s",
label,
contact.public_key[:12],
response_text,
)
return CommandResponse(
command=command,
response=response_text,
sender_timestamp=sender_timestamp,
)

View File

@@ -2,44 +2,25 @@ import asyncio
import logging
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.models import AppSettings, BotConfig
from app.models import AppSettings
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
def validate_bot_code(code: str, bot_name: str | None = None) -> None:
"""Validate bot code syntax. Raises HTTPException on error."""
if not code or not code.strip():
return # Empty code is valid (disables bot)
try:
compile(code, "<bot_code>", "exec")
except SyntaxError as e:
name_part = f"'{bot_name}' " if bot_name else ""
raise HTTPException(
status_code=400,
detail=f"Bot {name_part}has syntax error at line {e.lineno}: {e.msg}",
) from None
def validate_all_bots(bots: list[BotConfig]) -> None:
"""Validate all bots' code syntax. Raises HTTPException on first error."""
for bot in bots:
validate_bot_code(bot.code, bot.name)
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
ge=1,
le=1000,
description=(
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
"Configured radio contact capacity used for maintenance thresholds and "
"background refill behavior"
),
)
auto_decrypt_dm_on_advert: bool | None = Field(
@@ -55,50 +36,28 @@ class AppSettingsUpdate(BaseModel):
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
)
bots: list[BotConfig] | None = Field(
flood_scope: str | None = Field(
default=None,
description="List of bot configurations",
description="Outbound flood scope / region name (empty = disabled)",
)
mqtt_broker_host: str | None = Field(
blocked_keys: list[str] | None = Field(
default=None,
description="MQTT broker hostname (empty = disabled)",
description="Public keys whose messages are hidden from the UI",
)
mqtt_broker_port: int | None = Field(
blocked_names: list[str] | None = Field(
default=None,
ge=1,
le=65535,
description="MQTT broker port",
)
mqtt_username: str | None = Field(
default=None,
description="MQTT username (optional)",
)
mqtt_password: str | None = Field(
default=None,
description="MQTT password (optional)",
)
mqtt_use_tls: bool | None = Field(
default=None,
description="Whether to use TLS for MQTT connection",
)
mqtt_tls_insecure: bool | None = Field(
default=None,
description="Skip TLS certificate verification (for self-signed certs)",
)
mqtt_topic_prefix: str | None = Field(
default=None,
description="MQTT topic prefix",
)
mqtt_publish_messages: bool | None = Field(
default=None,
description="Whether to publish decrypted messages to MQTT",
)
mqtt_publish_raw_packets: bool | None = Field(
default=None,
description="Whether to publish raw packets to MQTT",
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):
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
@@ -157,38 +116,33 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating advert_interval to %d", interval)
kwargs["advert_interval"] = interval
if update.bots is not None:
validate_all_bots(update.bots)
logger.info("Updating bots (count=%d)", len(update.bots))
kwargs["bots"] = update.bots
# Block lists
if update.blocked_keys is not None:
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names
# MQTT fields
mqtt_fields = [
"mqtt_broker_host",
"mqtt_broker_port",
"mqtt_username",
"mqtt_password",
"mqtt_use_tls",
"mqtt_tls_insecure",
"mqtt_topic_prefix",
"mqtt_publish_messages",
"mqtt_publish_raw_packets",
]
mqtt_changed = False
for field in mqtt_fields:
value = getattr(update, field)
if value is not None:
kwargs[field] = value
mqtt_changed = True
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
kwargs["flood_scope"] = normalize_region_scope(update.flood_scope)
flood_scope_changed = True
if kwargs:
result = await AppSettingsRepository.update(**kwargs)
# Restart MQTT publisher if any MQTT settings changed
if mqtt_changed:
from app.mqtt import mqtt_publisher
# Apply flood scope to radio immediately if changed
if flood_scope_changed:
from app.services.radio_runtime import radio_runtime as radio_manager
await mqtt_publisher.restart(result)
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
@@ -208,16 +162,30 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.add_favorite(request.type, request.id)
# When a contact favorite changes, sync the radio so the contact is
# loaded/unloaded immediately rather than waiting for the next advert.
if request.type == "contact":
from app.radio_sync import sync_recent_contacts_to_radio
# When a contact is newly favorited, load just that contact to the radio
# immediately so DM ACK support does not wait for the next maintenance cycle.
if request.type == "contact" and not is_favorited:
from app.radio_sync import ensure_contact_on_radio
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
return result
@router.post("/blocked-keys/toggle", response_model=AppSettings)
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
"""Toggle a public key's blocked status."""
logger.info("Toggling blocked key: %s", request.key[:12])
return await AppSettingsRepository.toggle_blocked_key(request.key)
@router.post("/blocked-names/toggle", response_model=AppSettings)
async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
"""Toggle a display name's blocked status."""
logger.info("Toggling blocked name: %s", request.name)
return await AppSettingsRepository.toggle_blocked_name(request.name)
@router.post("/migrate", response_model=MigratePreferencesResponse)
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
"""Migrate all preferences from frontend localStorage to database.

View File

@@ -4,8 +4,8 @@ import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.radio import radio_manager
from app.routers.health import build_health_data
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import ws_manager
logger = logging.getLogger(__name__)

121
app/security.py Normal file
View File

@@ -0,0 +1,121 @@
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
from __future__ import annotations
import base64
import binascii
import json
import logging
import secrets
from typing import Any
from starlette.datastructures import Headers
logger = logging.getLogger(__name__)
_AUTH_REALM = "RemoteTerm"
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
class BasicAuthMiddleware:
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
self.app = app
self.username = username
self.password = password
self.realm = realm
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
def _is_authorized(self, scope: dict[str, Any]) -> bool:
headers = Headers(scope=scope)
authorization = headers.get("authorization")
if not authorization:
return False
scheme, _, token = authorization.partition(" ")
if not token or scheme.lower() != "basic":
return False
token = token.strip()
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
logger.debug("Rejecting malformed basic auth header")
return False
username, sep, password = decoded.partition(":")
if not sep:
return False
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
password, self.password
)
async def _send_http_unauthorized(self, send) -> None:
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def _send_websocket_unauthorized(self, send) -> None:
await send(
{
"type": "websocket.http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "websocket.http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def __call__(self, scope, receive, send) -> None:
scope_type = scope["type"]
if scope_type not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
if self._is_authorized(scope):
await self.app(scope, receive, send)
return
if scope_type == "http":
await self._send_http_unauthorized(send)
return
await self._send_websocket_unauthorized(send)
def add_optional_basic_auth_middleware(app, settings) -> None:
"""Enable app-wide basic auth when configured via environment variables."""
if not settings.basic_auth_enabled:
return
app.add_middleware(
BasicAuthMiddleware,
username=settings.basic_auth_username,
password=settings.basic_auth_password,
)

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Backend service-layer helpers."""

View File

@@ -0,0 +1,133 @@
"""Shared contact/message reconciliation helpers."""
import logging
from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository
logger = logging.getLogger(__name__)
async def promote_prefix_contacts_for_contact(
*,
public_key: str,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> list[str]:
"""Promote prefix-only placeholder contacts once a full key is known."""
normalized_key = public_key.lower()
promoted = await contact_repository.promote_prefix_placeholders(normalized_key)
if promoted:
(log or logger).info(
"Promoted %d prefix contact placeholder(s) for %s",
len(promoted),
normalized_key[:12],
)
return promoted
async def claim_prefix_messages_for_contact(
*,
public_key: str,
message_repository=MessageRepository,
log: logging.Logger | None = None,
) -> int:
"""Promote prefix-key DMs to a resolved full public key."""
normalized_key = public_key.lower()
claimed = await message_repository.claim_prefix_messages(normalized_key)
if claimed > 0:
(log or logger).info(
"Claimed %d prefix DM message(s) for contact %s",
claimed,
normalized_key[:12],
)
return claimed
async def backfill_channel_sender_for_contact(
*,
public_key: str,
contact_name: str | None,
message_repository=MessageRepository,
log: logging.Logger | None = None,
) -> int:
"""Backfill channel sender attribution once a contact name is known."""
if not contact_name:
return 0
normalized_key = public_key.lower()
backfilled = await message_repository.backfill_channel_sender_key(
normalized_key,
contact_name,
)
if backfilled > 0:
(log or logger).info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
contact_name,
)
return backfilled
async def reconcile_contact_messages(
*,
public_key: str,
contact_name: str | None,
message_repository=MessageRepository,
log: logging.Logger | None = None,
) -> tuple[int, int]:
"""Apply message reconciliation once a contact's identity is resolved."""
claimed = await claim_prefix_messages_for_contact(
public_key=public_key,
message_repository=message_repository,
log=log,
)
backfilled = await backfill_channel_sender_for_contact(
public_key=public_key,
contact_name=contact_name,
message_repository=message_repository,
log=log,
)
return claimed, backfilled
async def record_contact_name(
*,
public_key: str,
contact_name: str | None,
timestamp: int,
contact_name_history_repository=ContactNameHistoryRepository,
) -> bool:
"""Record contact name history when a non-empty name is available."""
if not contact_name:
return False
await contact_name_history_repository.record_name(
public_key.lower(),
contact_name,
timestamp,
)
return True
async def record_contact_name_and_reconcile(
*,
public_key: str,
contact_name: str | None,
timestamp: int,
message_repository=MessageRepository,
contact_name_history_repository=ContactNameHistoryRepository,
log: logging.Logger | None = None,
) -> tuple[int, int]:
"""Record name history, then reconcile message identity for the contact."""
await record_contact_name(
public_key=public_key,
contact_name=contact_name,
timestamp=timestamp,
contact_name_history_repository=contact_name_history_repository,
)
return await reconcile_contact_messages(
public_key=public_key,
contact_name=contact_name,
message_repository=message_repository,
log=log,
)

View File

@@ -0,0 +1,26 @@
"""Shared direct-message ACK application logic."""
from collections.abc import Callable
from typing import Any
from app.services import dm_ack_tracker
from app.services.messages import increment_ack_and_broadcast
BroadcastFn = Callable[..., Any]
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
"""Apply a DM ACK code using the shared pending/buffered state machine.
Returns True when the ACK matched a pending message, False when it was buffered.
"""
dm_ack_tracker.cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is None:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
return False
dm_ack_tracker.clear_pending_acks_for_message(message_id)
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
return True

View File

@@ -0,0 +1,85 @@
"""Shared pending ACK tracking for outgoing direct messages."""
import logging
import time
logger = logging.getLogger(__name__)
PendingAck = tuple[int, float, int]
BUFFERED_ACK_TTL_SECONDS = 30.0
_pending_acks: dict[str, PendingAck] = {}
_buffered_acks: dict[str, float] = {}
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
"""Track an expected ACK code for an outgoing direct message.
Returns True when the ACK was already observed and buffered before registration.
"""
buffered_at = _buffered_acks.pop(expected_ack, None)
if buffered_at is not None:
logger.debug(
"Matched buffered ACK %s immediately for message %d",
expected_ack,
message_id,
)
return True
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
logger.debug(
"Tracking pending ACK %s for message %d (timeout %dms)",
expected_ack,
message_id,
timeout_ms,
)
return False
def buffer_unmatched_ack(ack_code: str) -> None:
"""Remember an ACK that arrived before its message registration."""
_buffered_acks[ack_code] = time.time()
logger.debug("Buffered unmatched ACK %s for late registration", ack_code)
def cleanup_expired_acks() -> None:
"""Remove stale pending ACK entries."""
now = time.time()
expired_codes = [
code
for code, (_message_id, created_at, timeout_ms) in _pending_acks.items()
if now - created_at > (timeout_ms / 1000) * 2
]
for code in expired_codes:
del _pending_acks[code]
logger.debug("Expired pending ACK %s", code)
expired_buffered_codes = [
code
for code, buffered_at in _buffered_acks.items()
if now - buffered_at > BUFFERED_ACK_TTL_SECONDS
]
for code in expired_buffered_codes:
del _buffered_acks[code]
logger.debug("Expired buffered ACK %s", code)
def pop_pending_ack(ack_code: str) -> int | None:
"""Claim the tracked message ID for an ACK code if present."""
pending = _pending_acks.pop(ack_code, None)
if pending is None:
return None
message_id, _, _ = pending
return message_id
def clear_pending_acks_for_message(message_id: int) -> None:
"""Remove any still-pending ACK codes for a message once one ACK wins."""
sibling_codes = [
code
for code, (pending_message_id, _created_at, _timeout_ms) in _pending_acks.items()
if pending_message_id == message_id
]
for code in sibling_codes:
del _pending_acks[code]
logger.debug("Cleared sibling pending ACK %s for message %d", code, message_id)

371
app/services/dm_ingest.py Normal file
View File

@@ -0,0 +1,371 @@
import asyncio
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
MessageRepository,
RawPacketRepository,
)
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
from app.services.messages import (
broadcast_message,
build_message_model,
build_message_paths,
format_contact_log_target,
handle_duplicate_message,
reconcile_duplicate_message,
truncate_for_log,
)
if TYPE_CHECKING:
from app.decoder import DecryptedDirectMessage
logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
_decrypted_dm_store_lock = asyncio.Lock()
@dataclass(frozen=True)
class FallbackDirectMessageContext:
conversation_key: str
contact: Contact | None
sender_name: str | None
sender_key: str | None
skip_storage: bool = False
async def _prepare_resolved_contact(
contact: Contact,
*,
log: logging.Logger | None = None,
) -> tuple[str, bool]:
conversation_key = contact.public_key.lower()
await claim_prefix_messages_for_contact(public_key=conversation_key, log=log or logger)
if contact.type == CONTACT_TYPE_REPEATER:
return conversation_key, True
return conversation_key, False
async def resolve_fallback_direct_message_context(
*,
sender_public_key: str,
received_at: int,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> FallbackDirectMessageContext:
normalized_sender = sender_public_key.lower()
try:
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
except AmbiguousPublicKeyPrefixError:
(log or logger).warning(
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
sender_public_key,
)
contact = None
if contact is not None:
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=log)
return FallbackDirectMessageContext(
conversation_key=conversation_key,
contact=contact,
sender_name=contact.name,
sender_key=conversation_key,
skip_storage=skip_storage,
)
if normalized_sender:
placeholder_upsert = ContactUpsert(
public_key=normalized_sender,
type=0,
last_seen=received_at,
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
)
await contact_repository.upsert(placeholder_upsert)
contact = await contact_repository.get_by_key(normalized_sender)
if contact is not None:
broadcast_fn("contact", contact.model_dump())
return FallbackDirectMessageContext(
conversation_key=normalized_sender,
contact=contact,
sender_name=contact.name if contact else None,
sender_key=normalized_sender or None,
)
async def resolve_direct_message_sender_metadata(
*,
sender_public_key: str,
received_at: int,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> tuple[str | None, str | None]:
"""Resolve sender attribution for direct-message variants such as room-server posts."""
normalized_sender = sender_public_key.lower()
try:
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
except AmbiguousPublicKeyPrefixError:
(log or logger).warning(
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
sender_public_key,
)
contact = None
if contact is not None:
await claim_prefix_messages_for_contact(
public_key=contact.public_key.lower(), log=log or logger
)
return contact.name, contact.public_key.lower()
return None, normalized_sender or None
async def _store_direct_message(
*,
packet_id: int | None,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
outgoing: bool,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
realtime: bool,
broadcast_fn: BroadcastFn,
update_last_contacted_key: str | None,
best_effort_content_dedup: bool,
linked_packet_dedup: bool,
message_repository=MessageRepository,
contact_repository=ContactRepository,
raw_packet_repository=RawPacketRepository,
) -> Message | None:
async def store() -> Message | None:
if linked_packet_dedup and packet_id is not None:
linked_message_id = await raw_packet_repository.get_linked_message_id(packet_id)
if linked_message_id is not None:
existing_msg = await message_repository.get_by_id(linked_message_id)
if existing_msg is not None:
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
if best_effort_content_dedup:
existing_msg = await message_repository.get_by_content(
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
)
if existing_msg is not None:
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
msg_id = await message_repository.create(
msg_type="PRIV",
text=text,
conversation_key=conversation_key,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
signature=signature,
outgoing=outgoing,
sender_key=sender_key,
sender_name=sender_name,
)
if msg_id is None:
await handle_duplicate_message(
packet_id=packet_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
if packet_id is not None:
await raw_packet_repository.mark_decrypted(packet_id, msg_id)
message = build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=build_message_paths(path, received_at, path_len),
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
outgoing=outgoing,
sender_name=sender_name,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
if update_last_contacted_key:
await contact_repository.update_last_contacted(update_last_contacted_key, received_at)
return message
if linked_packet_dedup:
async with _decrypted_dm_store_lock:
return await store()
return await store()
async def ingest_decrypted_direct_message(
*,
packet_id: int,
decrypted: "DecryptedDirectMessage",
their_public_key: str,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
outgoing: bool = False,
realtime: bool = True,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
) -> Message | None:
conversation_key = their_public_key.lower()
if not outgoing and decrypted.txt_type == 1:
logger.debug(
"Skipping CLI response from %s (txt_type=1): %s",
conversation_key[:12],
(decrypted.message or "")[:50],
)
return None
contact = await contact_repository.get_by_key(conversation_key)
sender_name: str | None = None
sender_key: str | None = conversation_key if not outgoing else None
signature: str | None = None
if contact is not None:
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
if skip_storage:
logger.debug(
"Skipping message from repeater %s (CLI responses not stored): %s",
conversation_key[:12],
(decrypted.message or "")[:50],
)
return None
if not outgoing:
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
sender_name, sender_key = await resolve_direct_message_sender_metadata(
sender_public_key=decrypted.signed_sender_prefix,
received_at=received_at or int(time.time()),
broadcast_fn=broadcast_fn,
contact_repository=contact_repository,
log=logger,
)
signature = decrypted.signed_sender_prefix
else:
sender_name = contact.name
received = received_at or int(time.time())
message = await _store_direct_message(
packet_id=packet_id,
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
path=path,
path_len=path_len,
outgoing=outgoing,
txt_type=decrypted.txt_type,
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
realtime=realtime,
broadcast_fn=broadcast_fn,
update_last_contacted_key=conversation_key,
best_effort_content_dedup=outgoing,
linked_packet_dedup=True,
)
if message is None:
return None
logger.info(
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
truncate_for_log(decrypted.message),
format_contact_log_target(contact.name if contact else None, conversation_key),
message.id,
conversation_key,
outgoing,
)
return message
async def ingest_fallback_direct_message(
*,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
broadcast_fn: BroadcastFn,
update_last_contacted_key: str | None = None,
) -> Message | None:
return await _store_direct_message(
packet_id=None,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
outgoing=False,
txt_type=txt_type,
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
realtime=True,
broadcast_fn=broadcast_fn,
update_last_contacted_key=update_last_contacted_key,
best_effort_content_dedup=True,
linked_packet_dedup=False,
)

View File

@@ -0,0 +1,778 @@
"""Shared send/resend orchestration for outgoing messages."""
import asyncio
import logging
from collections.abc import Callable
from typing import Any
from fastapi import HTTPException
from meshcore import EventType
from app.models import ResendChannelMessageResponse
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
from app.services import dm_ack_tracker
from app.services.messages import (
build_message_model,
create_outgoing_channel_message,
create_outgoing_direct_message,
increment_ack_and_broadcast,
)
logger = logging.getLogger(__name__)
NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
"Send command was issued to the radio, but no response was heard back. "
"The message may or may not have sent successfully."
)
BroadcastFn = Callable[..., Any]
TrackAckFn = Callable[[str, int, int], bool]
NowFn = Callable[[], float]
OutgoingReservationKey = tuple[str, str, str]
RetryTaskScheduler = Callable[[Any], Any]
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
_outgoing_timestamp_reservations_lock = asyncio.Lock()
DM_SEND_MAX_ATTEMPTS = 3
DEFAULT_DM_ACK_TIMEOUT_MS = 10000
DM_RETRY_WAIT_MARGIN = 1.2
async def allocate_outgoing_sender_timestamp(
*,
message_repository,
msg_type: str,
conversation_key: str,
text: str,
requested_timestamp: int,
) -> int:
"""Pick a sender timestamp that will not collide with an existing stored message."""
reservation_key = (msg_type, conversation_key, text)
candidate = requested_timestamp
while True:
async with _outgoing_timestamp_reservations_lock:
reserved = _pending_outgoing_timestamp_reservations.get(reservation_key, set())
is_reserved = candidate in reserved
if is_reserved:
candidate += 1
continue
existing = await message_repository.get_by_content(
msg_type=msg_type,
conversation_key=conversation_key,
text=text,
sender_timestamp=candidate,
)
if existing is not None:
candidate += 1
continue
async with _outgoing_timestamp_reservations_lock:
reserved = _pending_outgoing_timestamp_reservations.setdefault(reservation_key, set())
if candidate in reserved:
candidate += 1
continue
reserved.add(candidate)
break
if candidate != requested_timestamp:
logger.info(
"Bumped outgoing %s timestamp for %s from %d to %d to avoid same-content collision",
msg_type,
conversation_key[:12],
requested_timestamp,
candidate,
)
return candidate
async def release_outgoing_sender_timestamp(
*,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int,
) -> None:
reservation_key = (msg_type, conversation_key, text)
async with _outgoing_timestamp_reservations_lock:
reserved = _pending_outgoing_timestamp_reservations.get(reservation_key)
if not reserved:
return
reserved.discard(sender_timestamp)
if not reserved:
_pending_outgoing_timestamp_reservations.pop(reservation_key, None)
async def send_channel_message_with_effective_scope(
*,
mc,
channel,
channel_key: str,
key_bytes: bytes,
text: str,
timestamp_bytes: bytes,
action_label: str,
radio_manager,
temp_radio_slot: int,
error_broadcast_fn: BroadcastFn,
app_settings_repository=AppSettingsRepository,
) -> Any:
"""Send a channel message, temporarily overriding flood scope when configured."""
override_scope = normalize_region_scope(channel.flood_scope_override)
baseline_scope = ""
if override_scope:
settings = await app_settings_repository.get()
baseline_scope = normalize_region_scope(settings.flood_scope)
if override_scope and override_scope != baseline_scope:
logger.info(
"Temporarily applying channel flood_scope override for %s: %r",
channel.name,
override_scope,
)
override_result = await mc.commands.set_flood_scope(override_scope)
if override_result is not None and override_result.type == EventType.ERROR:
logger.warning(
"Failed to apply channel flood_scope override for %s: %s",
channel.name,
override_result.payload,
)
raise HTTPException(
status_code=500,
detail=(
f"Failed to apply regional override {override_scope!r} before {action_label}: "
f"{override_result.payload}"
),
)
try:
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
channel_key,
preferred_slot=temp_radio_slot,
)
if needs_configure:
logger.debug(
"Loading channel %s into radio slot %d before %s%s",
channel.name,
channel_slot,
action_label,
(
f" (evicting cached {evicted_channel_key[:8]})"
if evicted_channel_key is not None
else ""
),
)
try:
set_result = await mc.commands.set_channel(
channel_idx=channel_slot,
channel_name=channel.name,
channel_secret=key_bytes,
)
except Exception:
if evicted_channel_key is not None:
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
raise
if set_result.type == EventType.ERROR:
if evicted_channel_key is not None:
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
logger.warning(
"Failed to set channel on radio slot %d before %s: %s",
channel_slot,
action_label,
set_result.payload,
)
raise HTTPException(
status_code=500,
detail=f"Failed to configure channel on radio before {action_label}",
)
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
else:
logger.debug(
"Reusing cached radio slot %d for channel %s before %s",
channel_slot,
channel.name,
action_label,
)
send_result = await mc.commands.send_chan_msg(
chan=channel_slot,
msg=text,
timestamp=timestamp_bytes,
)
if send_result is None:
logger.warning(
"No response from radio after %s for channel %s; send outcome is unknown",
action_label,
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if send_result.type == EventType.ERROR:
radio_manager.invalidate_cached_channel_slot(channel_key)
else:
radio_manager.note_channel_slot_used(channel_key)
return send_result
finally:
if override_scope and override_scope != baseline_scope:
try:
restore_result = await mc.commands.set_flood_scope(
baseline_scope if baseline_scope else ""
)
if restore_result is not None and restore_result.type == EventType.ERROR:
logger.error(
"Failed to restore baseline flood_scope after sending to %s: %s",
channel.name,
restore_result.payload,
)
error_broadcast_fn(
"Regional override restore failed",
(
f"Sent to {channel.name}, but restoring flood scope failed. "
"The radio may still be region-scoped. Consider rebooting the radio."
),
)
else:
logger.debug(
"Restored baseline flood_scope after channel send: %r",
baseline_scope or "(disabled)",
)
except Exception:
logger.exception(
"Failed to restore baseline flood_scope after sending to %s",
channel.name,
)
error_broadcast_fn(
"Regional override restore failed",
(
f"Sent to {channel.name}, but restoring flood scope failed. "
"The radio may still be region-scoped. Consider rebooting the radio."
),
)
def _extract_expected_ack_code(result: Any) -> str | None:
if result is None or result.type == EventType.ERROR:
return None
payload = result.payload or {}
expected_ack = payload.get("expected_ack")
if not expected_ack:
return None
return expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
def _get_ack_tracking_timeout_ms(result: Any) -> int:
if result is None or result.type == EventType.ERROR:
return DEFAULT_DM_ACK_TIMEOUT_MS
payload = result.payload or {}
suggested_timeout = payload.get("suggested_timeout")
if suggested_timeout is None:
return DEFAULT_DM_ACK_TIMEOUT_MS
try:
return max(1, int(suggested_timeout))
except (TypeError, ValueError):
return DEFAULT_DM_ACK_TIMEOUT_MS
def _get_direct_message_retry_timeout_ms(result: Any) -> int:
"""Return the ACK window to wait before retrying a DM.
The MeshCore firmware already computes and returns `suggested_timeout` in
`PACKET_MSG_SENT`, derived from estimated packet airtime and route mode.
We use that firmware-supplied window directly so retries do not fire before
the radio's own ACK timeout expires.
Sources:
- https://github.com/meshcore-dev/MeshCore/blob/main/src/helpers/BaseChatMesh.cpp
- https://github.com/meshcore-dev/MeshCore/blob/main/examples/companion_radio/MyMesh.cpp
- https://github.com/meshcore-dev/MeshCore/blob/main/docs/companion_protocol.md
"""
return _get_ack_tracking_timeout_ms(result)
async def _apply_direct_message_ack_tracking(
*,
result: Any,
message_id: int,
track_pending_ack_fn: TrackAckFn,
broadcast_fn: BroadcastFn,
) -> int:
ack_code = _extract_expected_ack_code(result)
if not ack_code:
return 0
timeout_ms = _get_ack_tracking_timeout_ms(result)
matched_immediately = track_pending_ack_fn(ack_code, message_id, timeout_ms) is True
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
if matched_immediately:
dm_ack_tracker.clear_pending_acks_for_message(message_id)
return await increment_ack_and_broadcast(
message_id=message_id,
broadcast_fn=broadcast_fn,
)
return 0
async def _is_message_acked(*, message_id: int, message_repository) -> bool:
acked_count, _paths = await message_repository.get_ack_and_paths(message_id)
return acked_count > 0
async def _retry_direct_message_until_acked(
*,
contact,
text: str,
message_id: int,
sender_timestamp: int,
radio_manager,
track_pending_ack_fn: TrackAckFn,
broadcast_fn: BroadcastFn,
wait_timeout_ms: int,
sleep_fn,
message_repository,
) -> None:
next_wait_timeout_ms = wait_timeout_ms
for attempt in range(1, DM_SEND_MAX_ATTEMPTS):
await sleep_fn((next_wait_timeout_ms / 1000) * DM_RETRY_WAIT_MARGIN)
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
return
try:
async with radio_manager.radio_operation("retry_direct_message") as mc:
contact_data = contact.to_radio_dict()
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning(
"Failed to reload contact %s on radio before DM retry: %s",
contact.public_key[:12],
add_result.payload,
)
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not cached_contact:
cached_contact = contact_data
if attempt == DM_SEND_MAX_ATTEMPTS - 1:
reset_result = await mc.commands.reset_path(contact.public_key)
if reset_result is None:
logger.warning(
"No response from radio for reset_path to %s before final DM retry",
contact.public_key[:12],
)
elif reset_result.type == EventType.ERROR:
logger.warning(
"Failed to reset path before final DM retry to %s: %s",
contact.public_key[:12],
reset_result.payload,
)
refreshed_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if refreshed_contact:
cached_contact = refreshed_contact
result = await mc.commands.send_msg(
dst=cached_contact,
msg=text,
timestamp=sender_timestamp,
attempt=attempt,
)
except Exception:
logger.exception(
"Background DM retry attempt %d/%d failed for %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
continue
if result is None:
logger.warning(
"No response from radio after background DM retry attempt %d/%d to %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
continue
if result.type == EventType.ERROR:
logger.warning(
"Background DM retry attempt %d/%d failed for %s: %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
result.payload,
)
continue
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
return
ack_code = _extract_expected_ack_code(result)
if not ack_code:
logger.warning(
"Background DM retry attempt %d/%d for %s returned no expected_ack; "
"stopping retries to avoid duplicate sends",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
return
next_wait_timeout_ms = _get_direct_message_retry_timeout_ms(result)
ack_count = await _apply_direct_message_ack_tracking(
result=result,
message_id=message_id,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
)
if ack_count > 0:
return
async def send_direct_message_to_contact(
*,
contact,
text: str,
radio_manager,
broadcast_fn: BroadcastFn,
track_pending_ack_fn: TrackAckFn,
now_fn: NowFn,
retry_task_scheduler: RetryTaskScheduler | None = None,
retry_sleep_fn=None,
message_repository=MessageRepository,
contact_repository=ContactRepository,
) -> Any:
"""Send a direct message and persist/broadcast the outgoing row."""
if retry_task_scheduler is None:
retry_task_scheduler = asyncio.create_task
if retry_sleep_fn is None:
retry_sleep_fn = asyncio.sleep
contact_data = contact.to_radio_dict()
sent_at: int | None = None
sender_timestamp: int | None = None
message = None
result = None
try:
async with radio_manager.radio_operation("send_direct_message") as mc:
logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12])
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not cached_contact:
cached_contact = contact_data
logger.info("Sending direct message to %s", contact.public_key[:12])
sent_at = int(now_fn())
sender_timestamp = await allocate_outgoing_sender_timestamp(
message_repository=message_repository,
msg_type="PRIV",
conversation_key=contact.public_key.lower(),
text=text,
requested_timestamp=sent_at,
)
result = await mc.commands.send_msg(
dst=cached_contact,
msg=text,
timestamp=sender_timestamp,
)
if result is None:
logger.warning(
"No response from radio after direct send to %s; send outcome is unknown",
contact.public_key[:12],
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
message = await create_outgoing_direct_message(
conversation_key=contact.public_key.lower(),
text=text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
finally:
if sender_timestamp is not None:
await release_outgoing_sender_timestamp(
msg_type="PRIV",
conversation_key=contact.public_key.lower(),
text=text,
sender_timestamp=sender_timestamp,
)
if sent_at is None or sender_timestamp is None or message is None or result is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
ack_code = _extract_expected_ack_code(result)
retry_timeout_ms = _get_direct_message_retry_timeout_ms(result)
ack_count = await _apply_direct_message_ack_tracking(
result=result,
message_id=message.id,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
)
if ack_count > 0:
message.acked = ack_count
return message
if DM_SEND_MAX_ATTEMPTS > 1 and ack_code:
retry_task_scheduler(
_retry_direct_message_until_acked(
contact=contact,
text=text,
message_id=message.id,
sender_timestamp=sender_timestamp,
radio_manager=radio_manager,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
wait_timeout_ms=retry_timeout_ms,
sleep_fn=retry_sleep_fn,
message_repository=message_repository,
)
)
return message
async def send_channel_message_to_channel(
*,
channel,
channel_key_upper: str,
key_bytes: bytes,
text: str,
radio_manager,
broadcast_fn: BroadcastFn,
error_broadcast_fn: BroadcastFn,
now_fn: NowFn,
temp_radio_slot: int,
message_repository=MessageRepository,
) -> Any:
"""Send a channel message and persist/broadcast the outgoing row."""
sent_at: int | None = None
sender_timestamp: int | None = None
radio_name = ""
our_public_key: str | None = None
text_with_sender = text
outgoing_message = None
try:
async with radio_manager.radio_operation("send_channel_message") as mc:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
text_with_sender = f"{radio_name}: {text}" if radio_name else text
logger.info("Sending channel message to %s: %s", channel.name, text[:50])
sent_at = int(now_fn())
sender_timestamp = await allocate_outgoing_sender_timestamp(
message_repository=message_repository,
msg_type="CHAN",
conversation_key=channel_key_upper,
text=text_with_sender,
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
result = await send_channel_message_with_effective_scope(
mc=mc,
channel=channel,
channel_key=channel_key_upper,
key_bytes=key_bytes,
text=text,
timestamp_bytes=timestamp_bytes,
action_label="sending message",
radio_manager=radio_manager,
temp_radio_slot=temp_radio_slot,
error_broadcast_fn=error_broadcast_fn,
)
if result is None:
logger.warning(
"No response from radio after channel send to %s; send outcome is unknown",
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send message: {result.payload}"
)
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
finally:
if sender_timestamp is not None:
await release_outgoing_sender_timestamp(
msg_type="CHAN",
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
)
if sent_at is None or sender_timestamp is None or outgoing_message is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
message_id = outgoing_message.id
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,
msg_type="CHAN",
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
paths=paths,
outgoing=True,
acked=acked_count,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
)
async def resend_channel_message_record(
*,
message,
channel,
new_timestamp: bool,
radio_manager,
broadcast_fn: BroadcastFn,
error_broadcast_fn: BroadcastFn,
now_fn: NowFn,
temp_radio_slot: int,
message_repository=MessageRepository,
) -> ResendChannelMessageResponse:
"""Resend a stored outgoing channel message."""
try:
key_bytes = bytes.fromhex(message.conversation_key)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid channel key format: {message.conversation_key}",
) from None
sent_at: int | None = None
sender_timestamp = message.sender_timestamp
timestamp_bytes = message.sender_timestamp.to_bytes(4, "little")
resend_public_key: str | None = None
radio_name = ""
new_message = None
stored_text = message.text
try:
async with radio_manager.radio_operation("resend_channel_message") as mc:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
text_to_send = message.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
if new_timestamp:
sent_at = int(now_fn())
sender_timestamp = await allocate_outgoing_sender_timestamp(
message_repository=message_repository,
msg_type="CHAN",
conversation_key=message.conversation_key,
text=stored_text,
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
result = await send_channel_message_with_effective_scope(
mc=mc,
channel=channel,
channel_key=message.conversation_key,
key_bytes=key_bytes,
text=text_to_send,
timestamp_bytes=timestamp_bytes,
action_label="resending message",
radio_manager=radio_manager,
temp_radio_slot=temp_radio_slot,
error_broadcast_fn=error_broadcast_fn,
)
if result is None:
logger.warning(
"No response from radio after channel resend to %s; send outcome is unknown",
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to resend message: {result.payload}",
)
if new_timestamp:
if sent_at is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
new_message = await create_outgoing_channel_message(
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if new_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store resent message - unexpected duplicate",
)
finally:
if new_timestamp and sent_at is not None:
await release_outgoing_sender_timestamp(
msg_type="CHAN",
conversation_key=message.conversation_key,
text=stored_text,
sender_timestamp=sender_timestamp,
)
if new_timestamp:
if sent_at is None or new_message is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
logger.info(
"Resent channel message %d as new message %d to %s",
message.id,
new_message.id,
channel.name,
)
return ResendChannelMessageResponse(
status="ok",
message_id=new_message.id,
message=new_message,
)
logger.info("Resent channel message %d to %s", message.id, channel.name)
return ResendChannelMessageResponse(status="ok", message_id=message.id)

461
app/services/messages.py Normal file
View File

@@ -0,0 +1,461 @@
import logging
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from app.models import Message, MessagePath
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
if TYPE_CHECKING:
from app.decoder import DecryptedDirectMessage
logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
LOG_MESSAGE_PREVIEW_LEN = 32
def truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
"""Return a compact single-line message preview for log output."""
normalized = " ".join(text.split())
if len(normalized) <= max_chars:
return normalized
return f"{normalized[:max_chars].rstrip()}..."
def _format_channel_log_target(channel_name: str | None, channel_key: str) -> str:
"""Return a human-friendly channel label for logs."""
return channel_name or channel_key
def format_contact_log_target(contact_name: str | None, public_key: str) -> str:
"""Return a human-friendly DM target label for logs."""
return contact_name or public_key[:12]
def build_message_paths(
path: str | None,
received_at: int,
path_len: int | None = None,
) -> list[MessagePath] | None:
"""Build the single-path list used by message payloads."""
return (
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
if path is not None
else None
)
def build_message_model(
*,
message_id: int,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int | None,
received_at: int,
paths: list[MessagePath] | None = None,
txt_type: int = 0,
signature: str | None = None,
sender_key: str | None = None,
outgoing: bool = False,
acked: int = 0,
sender_name: str | None = None,
channel_name: str | None = None,
) -> Message:
"""Build a Message model with the canonical backend payload shape."""
return Message(
id=message_id,
type=msg_type,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=paths,
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
outgoing=outgoing,
acked=acked,
sender_name=sender_name,
channel_name=channel_name,
)
def broadcast_message(
*,
message: Message,
broadcast_fn: BroadcastFn,
realtime: bool | None = None,
) -> None:
"""Broadcast a message payload, preserving the caller's broadcast signature."""
payload = message.model_dump()
if realtime is None:
broadcast_fn("message", payload)
else:
broadcast_fn("message", payload, realtime=realtime)
def broadcast_message_acked(
*,
message_id: int,
ack_count: int,
paths: list[MessagePath] | None,
broadcast_fn: BroadcastFn,
) -> None:
"""Broadcast a message_acked payload."""
broadcast_fn(
"message_acked",
{
"message_id": message_id,
"ack_count": ack_count,
"paths": [path.model_dump() for path in paths] if paths else [],
},
)
async def increment_ack_and_broadcast(
*,
message_id: int,
broadcast_fn: BroadcastFn,
) -> int:
"""Increment a message's ACK count and broadcast the update."""
ack_count = await MessageRepository.increment_ack_count(message_id)
broadcast_fn("message_acked", {"message_id": message_id, "ack_count": ack_count})
return ack_count
async def reconcile_duplicate_message(
*,
existing_msg: Message,
packet_id: int | None,
path: str | None,
received_at: int,
path_len: int | None,
broadcast_fn: BroadcastFn,
) -> None:
logger.debug(
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
existing_msg.type,
existing_msg.conversation_key[:12],
existing_msg.id,
existing_msg.outgoing,
)
if path is not None:
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
else:
paths = existing_msg.paths or []
if existing_msg.outgoing and existing_msg.type == "CHAN":
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
else:
ack_count = existing_msg.acked
if existing_msg.outgoing or path is not None:
broadcast_message_acked(
message_id=existing_msg.id,
ack_count=ack_count,
paths=paths,
broadcast_fn=broadcast_fn,
)
if packet_id is not None:
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
async def handle_duplicate_message(
*,
packet_id: int | None,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int,
outgoing: bool | None = None,
path: str | None,
received_at: int,
path_len: int | None = None,
broadcast_fn: BroadcastFn,
) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record."""
existing_msg = await MessageRepository.get_by_content(
msg_type=msg_type,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
)
if not existing_msg:
label = "message" if msg_type == "CHAN" else "DM"
logger.warning(
"Duplicate %s for %s but couldn't find existing",
label,
conversation_key[:12],
)
return
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
async def create_message_from_decrypted(
*,
packet_id: int,
channel_key: str,
sender: str | None,
message_text: str,
timestamp: int,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
channel_name: str | None = None,
realtime: bool = True,
broadcast_fn: BroadcastFn,
) -> int | None:
"""Store and broadcast a decrypted channel message."""
received = received_at or int(time.time())
text = f"{sender}: {message_text}" if sender else message_text
channel_key_normalized = channel_key.upper()
resolved_sender_key: str | None = None
if sender:
candidates = await ContactRepository.get_by_name(sender)
if len(candidates) == 1:
resolved_sender_key = candidates[0].public_key
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=text,
conversation_key=channel_key_normalized,
sender_timestamp=timestamp,
received_at=received,
path=path,
path_len=path_len,
sender_name=sender,
sender_key=resolved_sender_key,
)
if msg_id is None:
await handle_duplicate_message(
packet_id=packet_id,
msg_type="CHAN",
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
outgoing=None,
path=path,
received_at=received,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
logger.info(
'Stored channel message "%s" for %r (msg ID %d in chan ID %s)',
truncate_for_log(text),
_format_channel_log_target(channel_name, channel_key_normalized),
msg_id,
channel_key_normalized,
)
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
broadcast_message(
message=build_message_model(
message_id=msg_id,
msg_type="CHAN",
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
received_at=received,
paths=build_message_paths(path, received, path_len),
sender_name=sender,
sender_key=resolved_sender_key,
channel_name=channel_name,
),
broadcast_fn=broadcast_fn,
realtime=realtime,
)
return msg_id
async def create_dm_message_from_decrypted(
*,
packet_id: int,
decrypted: "DecryptedDirectMessage",
their_public_key: str,
our_public_key: str | None,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
outgoing: bool = False,
realtime: bool = True,
broadcast_fn: BroadcastFn,
) -> int | None:
"""Store and broadcast a decrypted direct message."""
from app.services.dm_ingest import ingest_decrypted_direct_message
message = await ingest_decrypted_direct_message(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=their_public_key,
received_at=received_at,
path=path,
path_len=path_len,
outgoing=outgoing,
realtime=realtime,
broadcast_fn=broadcast_fn,
)
return message.id if message is not None else None
async def create_fallback_channel_message(
*,
conversation_key: str,
message_text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
txt_type: int,
sender_name: str | None,
channel_name: str | None,
broadcast_fn: BroadcastFn,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast a CHANNEL_MSG_RECV fallback channel message."""
conversation_key_normalized = conversation_key.upper()
text = f"{sender_name}: {message_text}" if sender_name else message_text
resolved_sender_key: str | None = None
if sender_name:
candidates = await ContactRepository.get_by_name(sender_name)
if len(candidates) == 1:
resolved_sender_key = candidates[0].public_key
msg_id = await message_repository.create(
msg_type="CHAN",
text=text,
conversation_key=conversation_key_normalized,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
sender_name=sender_name,
sender_key=resolved_sender_key,
)
if msg_id is None:
await handle_duplicate_message(
packet_id=None,
msg_type="CHAN",
conversation_key=conversation_key_normalized,
text=text,
sender_timestamp=sender_timestamp,
outgoing=None,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
message = build_message_model(
message_id=msg_id,
msg_type="CHAN",
conversation_key=conversation_key_normalized,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=build_message_paths(path, received_at, path_len),
txt_type=txt_type,
sender_name=sender_name,
sender_key=resolved_sender_key,
channel_name=channel_name,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message
async def create_outgoing_direct_message(
*,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
broadcast_fn: BroadcastFn,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast an outgoing direct message."""
msg_id = await message_repository.create(
msg_type="PRIV",
text=text,
conversation_key=conversation_key,
sender_timestamp=sender_timestamp,
received_at=received_at,
outgoing=True,
)
if msg_id is None:
return None
message = build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
outgoing=True,
acked=0,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message
async def create_outgoing_channel_message(
*,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
sender_name: str | None,
sender_key: str | None,
channel_name: str | None,
broadcast_fn: BroadcastFn,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast an outgoing channel message."""
msg_id = await message_repository.create(
msg_type="CHAN",
text=text,
conversation_key=conversation_key,
sender_timestamp=sender_timestamp,
received_at=received_at,
outgoing=True,
sender_name=sender_name,
sender_key=sender_key,
)
if msg_id is None:
return None
message = build_message_model(
message_id=msg_id,
msg_type="CHAN",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
outgoing=True,
acked=0,
sender_name=sender_name,
sender_key=sender_key,
channel_name=channel_name,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message

View File

@@ -0,0 +1,121 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from meshcore import EventType
logger = logging.getLogger(__name__)
class RadioCommandServiceError(RuntimeError):
"""Base error for reusable radio command workflows."""
class PathHashModeUnsupportedError(RadioCommandServiceError):
"""Raised when firmware does not support path hash mode updates."""
class RadioCommandRejectedError(RadioCommandServiceError):
"""Raised when the radio reports an error for a command."""
class KeystoreRefreshError(RadioCommandServiceError):
"""Raised when server-side keystore refresh fails after import."""
async def apply_radio_config_update(
mc,
update,
*,
path_hash_mode_supported: bool,
set_path_hash_mode: Callable[[int], None],
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
) -> None:
"""Apply a validated radio-config update to the connected radio."""
if update.advert_location_source is not None:
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
logger.info(
"Setting advert location policy to %s",
update.advert_location_source,
)
result = await mc.commands.set_advert_loc_policy(advert_loc_policy)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set advert location policy: {result.payload}"
)
if update.multi_acks_enabled is not None:
multi_acks = 1 if update.multi_acks_enabled else 0
logger.info("Setting multi ACKs to %d", multi_acks)
result = await mc.commands.set_multi_acks(multi_acks)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
if update.lat is not None or update.lon is not None:
current_info = mc.self_info
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
logger.info("Setting radio coordinates to %f, %f", lat, lon)
await mc.commands.set_coords(lat=lat, lon=lon)
if update.tx_power is not None:
logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=update.tx_power)
if update.radio is not None:
logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
update.radio.freq,
update.radio.bw,
update.radio.sf,
update.radio.cr,
)
await mc.commands.set_radio(
freq=update.radio.freq,
bw=update.radio.bw,
sf=update.radio.sf,
cr=update.radio.cr,
)
if update.path_hash_mode is not None:
if not path_hash_mode_supported:
raise PathHashModeUnsupportedError("Firmware does not support path hash mode setting")
logger.info("Setting path hash mode to %d", update.path_hash_mode)
result = await mc.commands.set_path_hash_mode(update.path_hash_mode)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(f"Failed to set path hash mode: {result.payload}")
set_path_hash_mode(update.path_hash_mode)
await sync_radio_time_fn(mc)
# Commands like set_name() write to flash but don't update cached self_info.
# send_appstart() forces a fresh SELF_INFO so the response reflects changes.
await mc.commands.send_appstart()
async def import_private_key_and_refresh_keystore(
mc,
key_bytes: bytes,
*,
export_and_store_private_key_fn: Callable[[Any], Awaitable[bool]],
) -> None:
"""Import a private key and refresh the in-memory keystore immediately."""
result = await mc.commands.import_private_key(key_bytes)
if result.type == EventType.ERROR:
raise RadioCommandRejectedError(f"Failed to import private key: {result.payload}")
keystore_refreshed = await export_and_store_private_key_fn(mc)
if not keystore_refreshed:
logger.warning("Keystore refresh failed after import, retrying once")
keystore_refreshed = await export_and_store_private_key_fn(mc)
if not keystore_refreshed:
raise KeystoreRefreshError(
"Private key imported on radio, but server-side keystore refresh failed. "
"Reconnect to apply the new key for DM decryption."
)

View File

@@ -0,0 +1,371 @@
import asyncio
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
def _clean_device_string(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
def _decode_fixed_string(raw: bytes, start: int, length: int) -> str | None:
if len(raw) < start:
return None
return _clean_device_string(
raw[start : start + length].decode("utf-8", "ignore").replace("\0", "")
)
async def run_post_connect_setup(radio_manager) -> None:
"""Run shared radio initialization after a transport connection succeeds."""
from app.event_handlers import register_event_handlers
from app.keystore import export_and_store_private_key
from app.radio_sync import (
drain_pending_messages,
send_advertisement,
start_message_polling,
start_periodic_advert,
start_periodic_sync,
sync_and_offload_all,
sync_radio_time,
)
if not radio_manager.meshcore:
return
if radio_manager._setup_lock is None:
radio_manager._setup_lock = asyncio.Lock()
async def _setup_body() -> None:
if not radio_manager.meshcore:
return
radio_manager._setup_in_progress = True
radio_manager._setup_complete = False
try:
# Hold the operation lock for all radio I/O during setup.
# This prevents user-initiated operations (send message, etc.)
# from interleaving commands on the serial link.
await radio_manager._acquire_operation_lock("post_connect_setup", blocking=True)
try:
mc = radio_manager.meshcore
if not mc:
return
# Register event handlers against the locked, current transport.
register_event_handlers(mc)
await export_and_store_private_key(mc)
# Sync radio clock with system time
await sync_radio_time(mc)
# Apply flood scope from settings (best-effort; older firmware
# may not support set_flood_scope)
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
app_settings = await AppSettingsRepository.get()
scope = normalize_region_scope(app_settings.flood_scope)
try:
await mc.commands.set_flood_scope(scope if scope else "")
logger.info("Applied flood_scope=%r", scope or "(disabled)")
except Exception as exc:
logger.warning("set_flood_scope failed (firmware may not support it): %s", exc)
# Query path hash mode support (best-effort; older firmware won't report it).
# If the library's parsed payload is missing path_hash_mode (e.g. stale
# .pyc on WSL2 Windows mounts), fall back to raw-frame extraction.
reader = mc._reader
_original_handle_rx = reader.handle_rx
_captured_frame: list[bytes] = []
async def _capture_handle_rx(data: bytearray) -> None:
from meshcore.packets import PacketType
if len(data) > 0 and data[0] == PacketType.DEVICE_INFO.value:
_captured_frame.append(bytes(data))
return await _original_handle_rx(data)
reader.handle_rx = _capture_handle_rx
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
try:
device_query = await mc.commands.send_device_query()
payload = (
device_query.payload
if device_query is not None and isinstance(device_query.payload, dict)
else {}
)
payload_max_contacts = payload.get("max_contacts")
if isinstance(payload_max_contacts, int):
radio_manager.max_contacts = max(1, payload_max_contacts)
payload_max_channels = payload.get("max_channels")
if isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, payload_max_channels)
radio_manager.device_model = _clean_device_string(payload.get("model"))
radio_manager.firmware_build = _clean_device_string(payload.get("fw_build"))
radio_manager.firmware_version = _clean_device_string(payload.get("ver"))
fw_ver = payload.get("fw ver")
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
if payload_reports_device_info:
radio_manager.device_info_loaded = True
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
radio_manager.path_hash_mode = payload["path_hash_mode"]
radio_manager.path_hash_mode_supported = True
if _captured_frame:
# Raw-frame fallback / completion:
# byte 1 = fw_ver, byte 2 = max_contacts/2, byte 3 = max_channels,
# bytes 8:20 = fw_build, 20:60 = model, 60:80 = ver, byte 81 = path_hash_mode
raw = _captured_frame[-1]
fw_ver = raw[1] if len(raw) > 1 else 0
if fw_ver >= 3:
radio_manager.device_info_loaded = True
if radio_manager.max_contacts is None and len(raw) >= 3:
radio_manager.max_contacts = max(1, raw[2] * 2)
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, raw[3])
if radio_manager.firmware_build is None:
radio_manager.firmware_build = _decode_fixed_string(raw, 8, 12)
if radio_manager.device_model is None:
radio_manager.device_model = _decode_fixed_string(raw, 20, 40)
if radio_manager.firmware_version is None:
radio_manager.firmware_version = _decode_fixed_string(raw, 60, 20)
if (
not radio_manager.path_hash_mode_supported
and fw_ver >= 10
and len(raw) >= 82
):
radio_manager.path_hash_mode = raw[81]
radio_manager.path_hash_mode_supported = True
logger.warning(
"path_hash_mode=%d extracted from raw frame "
"(stale .pyc? try: rm %s)",
radio_manager.path_hash_mode,
getattr(
__import__("meshcore.reader", fromlist=["reader"]),
"__cached__",
"meshcore __pycache__/reader.*.pyc",
),
)
if radio_manager.path_hash_mode_supported:
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
else:
logger.debug("Firmware does not report path_hash_mode")
if radio_manager.device_info_loaded:
logger.info(
"Radio device info: model=%s build=%s version=%s max_contacts=%s max_channels=%d",
radio_manager.device_model or "unknown",
radio_manager.firmware_build or "unknown",
radio_manager.firmware_version or "unknown",
radio_manager.max_contacts
if radio_manager.max_contacts is not None
else "unknown",
radio_manager.max_channels,
)
try:
time_result = await mc.commands.get_time()
radio_time = (
time_result.payload.get("time")
if time_result is not None and time_result.payload
else None
)
if isinstance(radio_time, int):
logger.info(
"Radio clock at connect: epoch=%d utc=%s",
radio_time,
datetime.fromtimestamp(radio_time, timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S UTC"
),
)
except Exception as exc:
logger.debug("Failed to query radio clock after device info: %s", exc)
logger.info("Max channel slots: %d", radio_manager.max_channels)
except Exception as exc:
logger.debug("Failed to query device info capabilities: %s", exc)
finally:
reader.handle_rx = _original_handle_rx
# 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)
radio_manager.clear_pending_message_channel_slots()
await mc.start_auto_message_fetching()
logger.info("Auto message fetching started")
finally:
radio_manager._release_operation_lock("post_connect_setup")
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
radio_manager._setup_complete = True
finally:
radio_manager._setup_in_progress = False
async with radio_manager._setup_lock:
await asyncio.wait_for(_setup_body(), timeout=POST_CONNECT_SETUP_TIMEOUT_SECONDS)
logger.info("Post-connect setup complete")
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> bool:
"""Finish setup for an already-connected radio and optionally broadcast health."""
from app.websocket import broadcast_error, broadcast_health
if not radio_manager.connection_desired:
if radio_manager.is_connected:
await radio_manager.disconnect()
return False
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
try:
await radio_manager.post_connect_setup()
break
except asyncio.TimeoutError as exc:
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
logger.warning(
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
attempt,
POST_CONNECT_SETUP_MAX_ATTEMPTS,
)
continue
logger.error(
"Post-connect setup timed out after %ds on %d attempts. Initial radio offload "
"took too long; something is probably wrong.",
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
POST_CONNECT_SETUP_MAX_ATTEMPTS,
)
broadcast_error(
"Radio startup appears stuck",
"Initial radio offload took too long. Reboot the radio and restart the server.",
)
raise RuntimeError("Post-connect setup timed out") from exc
if not radio_manager.connection_desired:
if radio_manager.is_connected:
await radio_manager.disconnect()
return False
radio_manager._last_connected = True
if broadcast_on_success:
broadcast_health(True, radio_manager.connection_info)
return True
async def reconnect_and_prepare_radio(
radio_manager,
*,
broadcast_on_success: bool = True,
) -> bool:
"""Reconnect the transport, then run post-connect setup before reporting healthy."""
connected = await radio_manager.reconnect(broadcast_on_success=False)
if not connected:
return False
return await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
async def connection_monitor_loop(radio_manager) -> None:
"""Monitor radio health and keep transport/setup state converged."""
from app.websocket import broadcast_health
check_interval_seconds = 5
unresponsive_threshold = 3
consecutive_setup_failures = 0
while True:
try:
await asyncio.sleep(check_interval_seconds)
current_connected = radio_manager.is_connected
connection_desired = radio_manager.connection_desired
if radio_manager._last_connected and not current_connected:
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, radio_manager.connection_info)
radio_manager._last_connected = False
consecutive_setup_failures = 0
if not connection_desired:
if current_connected:
logger.info("Radio connection paused by operator; disconnecting transport")
await radio_manager.disconnect()
consecutive_setup_failures = 0
continue
if not current_connected:
if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio(
radio_manager,
broadcast_on_success=True,
):
consecutive_setup_failures = 0
elif not radio_manager._last_connected and current_connected:
logger.info("Radio connection restored")
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
consecutive_setup_failures = 0
elif (
current_connected
and not radio_manager.is_setup_complete
and not radio_manager.is_setup_in_progress
):
logger.info("Retrying post-connect setup...")
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
consecutive_setup_failures = 0
except asyncio.CancelledError:
break
except Exception as e:
consecutive_setup_failures += 1
if consecutive_setup_failures == unresponsive_threshold:
logger.error(
"Post-connect setup has failed %d times in a row. "
"The radio port appears open but the radio is not "
"responding to commands. Common causes: another "
"process has the serial port open (check for other "
"RemoteTerm instances, serial monitors, etc.), the "
"firmware is in repeater mode (not client), or the "
"radio needs a power cycle. Will keep retrying.",
consecutive_setup_failures,
)
elif consecutive_setup_failures < unresponsive_threshold:
logger.exception("Error in connection monitor, continuing: %s", e)

View File

@@ -0,0 +1,93 @@
"""Shared access seam over the global RadioManager instance.
This module deliberately keeps behavior thin and forwarding-only. The goal is
to reduce direct `app.radio.radio_manager` imports across routers and helpers
without changing radio lifecycle, lock, or connection semantics.
"""
from collections.abc import Callable
from contextlib import asynccontextmanager
from typing import Any
from fastapi import HTTPException
import app.radio as radio_module
class RadioRuntime:
"""Thin forwarding wrapper around the process-global RadioManager."""
def __init__(self, manager_or_getter=None):
if manager_or_getter is None:
self._manager_getter: Callable[[], Any] = lambda: radio_module.radio_manager
elif callable(manager_or_getter):
self._manager_getter = manager_or_getter
else:
self._manager_getter = lambda: manager_or_getter
@property
def manager(self) -> Any:
return self._manager_getter()
def __getattr__(self, name: str) -> Any:
"""Forward unknown attributes to the current global manager."""
return getattr(self.manager, name)
@staticmethod
def _is_local_runtime_attr(name: str) -> bool:
return name.startswith("_") or hasattr(RadioRuntime, name)
def __setattr__(self, name: str, value: Any) -> None:
if self._is_local_runtime_attr(name):
object.__setattr__(self, name, value)
return
setattr(self.manager, name, value)
def __delattr__(self, name: str) -> None:
if self._is_local_runtime_attr(name):
object.__delattr__(self, name)
return
delattr(self.manager, name)
def require_connected(self):
"""Return MeshCore when available, mirroring existing HTTP semantics."""
if self.is_setup_in_progress:
raise HTTPException(status_code=503, detail="Radio is initializing")
if not self.is_connected:
raise HTTPException(status_code=503, detail="Radio not connected")
mc = self.meshcore
if mc is None:
raise HTTPException(status_code=503, detail="Radio not connected")
return mc
@asynccontextmanager
async def radio_operation(self, name: str, **kwargs):
async with self.manager.radio_operation(name, **kwargs) as mc:
yield mc
async def start_connection_monitor(self) -> None:
await self.manager.start_connection_monitor()
async def stop_connection_monitor(self) -> None:
await self.manager.stop_connection_monitor()
async def disconnect(self) -> None:
await self.manager.disconnect()
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> bool:
from app.services.radio_lifecycle import prepare_connected_radio
return await prepare_connected_radio(
self.manager, broadcast_on_success=broadcast_on_success
)
async def reconnect_and_prepare(self, *, broadcast_on_success: bool = True) -> bool:
from app.services.radio_lifecycle import reconnect_and_prepare_radio
return await reconnect_and_prepare_radio(
self.manager,
broadcast_on_success=broadcast_on_success,
)
radio_runtime = RadioRuntime()

149
app/version_info.py Normal file
View File

@@ -0,0 +1,149 @@
"""Unified application version/build metadata resolution.
Resolution order:
- version: installed package metadata, ``APP_VERSION`` env, ``build_info.json``, ``pyproject.toml``
- commit: local git, ``COMMIT_HASH``/``VITE_COMMIT_HASH`` env, ``build_info.json``
This keeps backend surfaces, release bundles, and Docker builds aligned.
"""
from __future__ import annotations
import importlib.metadata
import json
import os
import subprocess
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
import tomllib
RELEASE_BUILD_INFO_FILENAME = "build_info.json"
PROJECT_NAME = "remoteterm-meshcore"
@dataclass(frozen=True)
class AppBuildInfo:
version: str
version_source: str
commit_hash: str | None
commit_source: str | None
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _read_build_info(root: Path) -> dict[str, Any] | None:
build_info_path = root / RELEASE_BUILD_INFO_FILENAME
try:
data = json.loads(build_info_path.read_text())
except Exception:
return None
return data if isinstance(data, dict) else None
def _package_metadata_version() -> str | None:
try:
return importlib.metadata.version(PROJECT_NAME)
except Exception:
return None
def _env_version() -> str | None:
value = os.getenv("APP_VERSION")
return value.strip() if value and value.strip() else None
def _build_info_version(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("version")
return value.strip() if isinstance(value, str) and value.strip() else None
def _pyproject_version(root: Path) -> str | None:
try:
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
project = pyproject.get("project")
if isinstance(project, dict):
version = project.get("version")
if isinstance(version, str) and version.strip():
return version.strip()
except Exception:
return None
return None
def _git_output(root: Path, *args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=root,
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
output = result.stdout.strip()
return output or None
def _env_commit_hash() -> str | None:
for name in ("COMMIT_HASH", "VITE_COMMIT_HASH"):
value = os.getenv(name)
if value and value.strip():
return value.strip()[:8]
return None
def _build_info_commit_hash(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("commit_hash")
return value.strip()[:8] if isinstance(value, str) and value.strip() else None
@lru_cache(maxsize=1)
def get_app_build_info() -> AppBuildInfo:
root = repo_root()
build_info = _read_build_info(root)
version = _package_metadata_version()
version_source = "package_metadata"
if version is None:
version = _env_version()
version_source = "env"
if version is None:
version = _build_info_version(build_info)
version_source = "build_info"
if version is None:
version = _pyproject_version(root)
version_source = "pyproject"
if version is None:
version = "0.0.0"
version_source = "fallback"
commit_hash = _git_output(root, "rev-parse", "--short", "HEAD")
commit_source: str | None = "git" if commit_hash else None
if commit_hash is None:
commit_hash = _env_commit_hash()
commit_source = "env" if commit_hash else None
if commit_hash is None:
commit_hash = _build_info_commit_hash(build_info)
commit_source = "build_info" if commit_hash else None
return AppBuildInfo(
version=version,
version_source=version_source,
commit_hash=commit_hash,
commit_source=commit_source,
)
def git_output(*args: str) -> str | None:
"""Shared git helper for debug surfaces that still need live repo state."""
return _git_output(repo_root(), *args)

View File

@@ -1,12 +1,13 @@
"""WebSocket manager for real-time updates."""
import asyncio
import json
import logging
from typing import Any
from fastapi import WebSocket
from app.events import dump_ws_event
logger = logging.getLogger(__name__)
# Timeout for individual WebSocket send operations (seconds)
@@ -45,7 +46,7 @@ class WebSocketManager:
if not self.active_connections:
return
message = json.dumps({"type": event_type, "data": data})
message = dump_ws_event(event_type, data)
# Copy connection list under lock to avoid holding lock during I/O
async with self._lock:
@@ -81,7 +82,7 @@ class WebSocketManager:
async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None:
"""Send an event to a specific client."""
message = json.dumps({"type": event_type, "data": data})
message = dump_ws_event(event_type, data)
try:
await websocket.send_text(message)
except Exception as e:
@@ -92,17 +93,26 @@ class WebSocketManager:
ws_manager = WebSocketManager()
def broadcast_event(event_type: str, data: dict) -> None:
def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> None:
"""Schedule a broadcast without blocking.
Convenience function that creates an asyncio task to broadcast
an event to all connected WebSocket clients and forward to MQTT.
an event to all connected WebSocket clients and forward to fanout modules.
Args:
event_type: Event type string (e.g. "message", "raw_packet")
data: Event payload dict
realtime: If False, skip fanout dispatch (used for historical decryption)
"""
asyncio.create_task(ws_manager.broadcast(event_type, data))
from app.mqtt import mqtt_broadcast
if realtime:
from app.fanout.manager import fanout_manager
mqtt_broadcast(event_type, data)
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:

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -13,7 +13,7 @@ services:
# Set your serial device for passthrough here! #
################################################
devices:
- /dev/ttyUSB0:/dev/ttyUSB0
- /dev/ttyACM0:/dev/ttyUSB0
environment:
MESHCORE_DATABASE_PATH: data/meshcore.db

1
frontend/.npmrc Normal file
View File

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

View File

@@ -12,32 +12,58 @@ Keep it aligned with `frontend/src` source code.
- Tailwind utility classes + local CSS (`index.css`, `styles.css`)
- Sonner (toasts)
- Leaflet / react-leaflet (map)
- `@michaelhart/meshcore-decoder` installed via npm alias to `meshcore-decoder-multibyte-patch`
- `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker)
- Multibyte-aware decoder build published as `meshcore-decoder-multibyte-patch`
## Code Ethos
- Prefer fewer, stronger modules over many thin wrappers.
- Split code only when the new hook/component owns a real invariant or workflow.
- Keep one reasoning unit readable in one place, even if that file is moderately large.
- Avoid dedicated files whose main job is pass-through, prop bundling, or renaming.
- For this repo, "locally dense but semantically obvious" is better than indirection-heavy "clean architecture".
- When refactoring, preserve behavior first and add tests around the seam being moved.
## Frontend Map
```text
frontend/src/
├── main.tsx # React entry point (StrictMode, root render)
├── App.tsx # App shell and orchestration
├── App.tsx # Data/orchestration entry that wires hooks into AppShell
├── api.ts # Typed REST client
├── types.ts # Shared TS contracts
├── useWebSocket.ts # WS lifecycle + event dispatch
├── messageCache.ts # Conversation-scoped cache
├── wsEvents.ts # Typed WS event parsing / discriminated union
├── prefetch.ts # Consumes prefetched API promises started in index.html
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
├── themes.css # Color theme definitions
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── hooks/
│ ├── index.ts # Central re-export of all hooks
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
│ ├── useConversationActions.ts # Send/resend/trace/block conversation actions
│ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state
│ ├── useConversationMessages.ts # Conversation timeline loading, cache restore, jump-target loading, pagination, dedup, pending ACK buffering
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
│ ├── useRadioControl.ts # Radio health/config state, reconnection
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
├── components/
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
│ ├── visualizer/
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
│ │ ├── VisualizerControls.tsx # Visualizer legends and control panel overlay
│ │ ├── VisualizerTooltip.tsx # Hover/pin node detail overlay
│ │ └── shared.ts # Graph node/link types and shared rendering helpers
│ └── ...
├── utils/
│ ├── urlHash.ts # Hash parsing and encoding
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
@@ -47,11 +73,15 @@ frontend/src/
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
── radioPresets.ts # LoRa radio preset configurations
── radioPresets.ts # LoRa radio preset configurations
│ └── theme.ts # Theme switching helpers
├── components/
│ ├── StatusBar.tsx
│ ├── Sidebar.tsx
@@ -59,35 +89,40 @@ frontend/src/
│ ├── 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)
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Preset, freq/bw/sf/cr, txPower, lat/lon
│ │ ├── SettingsIdentitySection.tsx # Name, keys, advert interval
│ │ ├── SettingsConnectivitySection.tsx # Connection status, max contacts, reboot
│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ── SettingsAboutSection.tsx # Version, author, license, links
│ │ ── SettingsAboutSection.tsx # Version, author, license, links
│ │ └── ThemeSelector.tsx # Color theme picker
│ ├── repeater/
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
│ │ ├── RepeaterAclPane.tsx # Permission table
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio settings + advert intervals
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
@@ -102,9 +137,13 @@ frontend/src/
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── contactAvatar.test.ts
├── contactInfoPane.test.tsx
├── integration.test.ts
├── mapView.test.tsx
├── messageCache.test.ts
├── messageList.test.tsx
├── messageParser.test.ts
├── rawPacketList.test.tsx
├── pathUtils.test.ts
├── prefetch.test.ts
├── radioPresets.test.ts
@@ -114,39 +153,77 @@ frontend/src/
├── repeaterLogin.test.tsx
├── repeaterMessageParsing.test.ts
├── localLabel.test.ts
├── messageInput.test.tsx
├── newMessageModal.test.tsx
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── statusBar.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
├── channelInfoKeyVisibility.test.tsx
├── chatHeaderKeyVisibility.test.tsx
├── searchView.test.tsx
├── useConversationActions.test.ts
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useConversationNavigation.test.ts
├── useAppShell.test.ts
├── useRepeaterDashboard.test.ts
├── useContactsAndChannels.test.ts
├── useRealtimeAppState.test.ts
├── useUnreadCounts.test.ts
├── useWebSocket.dispatch.test.ts
── useWebSocket.lifecycle.test.ts
── useWebSocket.lifecycle.test.ts
└── wsEvents.test.ts
```
## Architecture Notes
### State ownership
`App.tsx` orchestrates high-level state and delegates to hooks:
`App.tsx` is now a thin composition entrypoint over the hook layer. `AppShell.tsx` owns shell layout/composition:
- local label banner
- status bar
- desktop/mobile sidebar container
- search/settings surface switching
- global cracker mount/focus behavior
- new-message modal and info panes
High-level state is delegated to hooks:
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
- `useRadioControl`: radio health/config state, reconnect/reboot polling
- `useAppSettings`: settings CRUD, favorites, preferences migration
- `useContactsAndChannels`: contact/channel lists, creation, deletion
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationMessages`: fetch, pagination, dedup/update helpers
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
`App.tsx` intentionally still does the final `AppShell` prop assembly. That composition layer is considered acceptable here because it keeps the shell contract visible in one place and avoids a prop-bundling hook with little original logic.
`ConversationPane.tsx` owns the main active-conversation surface branching:
- empty state
- map view
- visualizer
- raw packet feed
- repeater dashboard
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
### Initial load + realtime
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
- WebSocket: realtime deltas/events.
- On reconnect, the app refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
### New Message modal
`NewMessageModal` intentionally preserves form state (tab, inputs, checkboxes) when closed and reopened. The component instance persists across open/close cycles. This is by design so users don't lose in-progress input if they accidentally dismiss the dialog.
`NewMessageModal` resets form state on close. The component instance persists across open/close cycles for smooth animations.
### Message behavior
@@ -154,22 +231,37 @@ frontend/src/
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
- ACK/repeat updates arrive as `message_acked` events.
- Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`.
- Conversation-scoped message caching now lives inside `useConversationMessages.ts` rather than a standalone `messageCache.ts` module. If you touch message timeline restore/dedup/reconnect behavior, start there.
- `contact_resolved` is a real-time identity migration event, not just a contact-list update. Changes in that area need to consider active conversation state, cached messages, unread state keys, and reconnect reconciliation together.
### Visualizer behavior
- `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs).
- `PacketVisualizer3D.tsx` is now a thin composition shell over visualizer-specific hooks/components in `components/visualizer/`.
- `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame.
- Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets.
- Raw-packet decoding in `RawPacketList.tsx` and `visualizerUtils.ts` relies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backend `path_utils.py`.
- Raw packet events carry both:
- `id`: backend storage row identity (payload-level dedup)
- `observation_id`: realtime per-arrival identity (session fidelity)
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
### Radio settings behavior
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- `SettingsRadioSection.tsx` also exposes `multi_acks_enabled` as a checkbox for the radio's extra direct-ACK transmission behavior.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
## WebSocket (`useWebSocket.ts`)
- Auto reconnect (3s) with cleanup guard on unmount.
- Heartbeat ping every 30s.
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored).
- Incoming JSON is parsed through `wsEvents.ts`, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes.
- Event handlers: `health`, `message`, `contact`, `contact_resolved`, `channel`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
## URL Hash Navigation (`utils/urlHash.ts`)
@@ -179,6 +271,7 @@ Supported routes:
- `#map`
- `#map/focus/{pubkey_or_prefix}`
- `#visualizer`
- `#search`
- `#channel/{channelKey}`
- `#channel/{channelKey}/{label}`
- `#contact/{publicKey}`
@@ -225,17 +318,22 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `bots`
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `flood_scope`
- `blocked_keys`, `blocked_names`
`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
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`:
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:
- Header: avatar, name, public key, type badge, on-radio badge
- Info grid: last seen, first heard, last contacted, distance, hops
@@ -244,19 +342,32 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
- Most active rooms (clickable → navigate to channel)
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
- Advert observation rate
- Nearest repeaters (resolved from first-hop path prefixes)
- Recent advert paths
- Recent advert paths (informational only; not part of DM route selection)
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
## Channel Info Pane
Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right drawer) showing channel details fetched from `GET /api/channels/{key}/detail`:
- Header: channel name, key (clickable copy), type badge (hashtag/private key), on-radio badge
- Favorite toggle
- Message activity: time-windowed counts (1h, 24h, 48h, 7d, all time) + unique senders
- First message date
- Top senders in last 24h (name + count)
State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot.
## Repeater Dashboard
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
@@ -264,6 +375,17 @@ For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
## Message Search Pane
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
- **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation.
- **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page.
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
- **Jump-to-message**: `useConversationMessages` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
## Styling
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
@@ -277,7 +399,7 @@ Do not rely on old class-only layout assumptions.
## Testing
Run all quality checks (backend + frontend, parallelized) from the repo root:
Run all quality checks (backend + frontend) from the repo root:
```bash
./scripts/all_quality.sh
@@ -291,6 +413,10 @@ npm run test:run
npm run build
```
`npm run packaged-build` is release-only. It writes the fallback `frontend/prebuilt`
directory used by the downloadable prebuilt release zip; normal development and
validation should stick to `npm run build`.
When touching cross-layer contracts, also run backend tests from repo root:
```bash
@@ -299,6 +425,10 @@ PYTHONPATH=. uv run pytest tests/ -v
## Errata & Known Non-Issues
### Contacts use mention styling for unread DMs
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions.
### RawPacketList always scrolls to bottom
`RawPacketList` unconditionally scrolls to the latest packet on every update. This is intentional — the packet feed is a live status display, not an interactive log meant for lingering or long-term analysis. Users watching it want to see the newest packet, not hold a scroll position.

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

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