From f7d6599310aafbb3a18ea77510f8906a67b2e910 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Tue, 14 Jul 2026 22:36:07 +0800 Subject: [PATCH] feat(reticulum): complete messaging, calls, and P4 support Add official Sideband/LXST messaging and telephony flows, realtime resource ownership, call and ping UI, durable unread state, and bounded Nomad/Micron browsing. Integrate ESP32-P4/C6 gateway transport, TinyUSB CDC, ES8311 audio, shared Codec2 call media, Reticulum defaults, compatibility settings, and conformance coverage. --- CHANGELOG.md | Bin 145280 -> 140590 bytes .../src/esp32_lvgl_idf_app_facade_runtime.cpp | 223 +- .../src/esp32_lvgl_startup_runtime.cpp | 5 + apps/linux_sim_shell/CMakeLists.txt | 41 + ...inux_reticulum_directory_runtime_smoke.cpp | 42 + .../boards/tlora_pager/tlora_pager_board.h | 1 + boards/tlora_pager/src/tlora_pager_board.cpp | 141 +- .../esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake | 39 + builds/esp_idf/main/CMakeLists.txt | 2 + builds/esp_idf/main/idf_component.yml | 2 + .../t_display_p4_tft/sdkconfig.defaults | 10 +- cmake/TrailMateLinuxSources.cmake | 3 + dependencies.lock | 35 +- docs/reticulum/micron-support-corpus.md | 50 + docs/sound/call.mp3 | Bin 0 -> 1633 bytes .../components/tm_hostlink/tm_hostlink.c | 24 + .../components/tm_hostlink/tm_hostlink_sdio.c | 33 +- .../tm_services/include/tm_services.h | 1 + .../components/tm_services/tm_services.c | 16 +- .../components/tm_wifi/CMakeLists.txt | 4 +- .../components/tm_wifi/include/tm_wifi_tcp.h | 18 + .../components/tm_wifi/tm_wifi_tcp.c | 388 ++++ firmware/c6_companion/main/app_main.c | 2 + .../include/chat/domain/chat_types.h | 15 +- .../include/chat/domain/mesh_peer_directory.h | 4 + .../include/chat/infra/lxmf/lxmf_wire.h | 39 + .../include/chat/infra/mesh_incoming_queue.h | 3 + .../infra/reticulum/lxst_telephony_wire.h | 75 + .../core_chat/src/infra/lxmf/lxmf_wire.cpp | 339 +++- .../src/infra/mesh_peer_directory_core.cpp | 20 + .../infra/reticulum/lxst_telephony_wire.cpp | 605 ++++++ .../core_chat/src/usecase/chat_service.cpp | 6 +- .../test_mesh_peer_directory_contract.cpp | 37 + .../include/hostlink/c6/c6_protocol.h | 27 + modules/core_sys/include/app/app_config.h | 2 +- .../ui/audio/call_notification_adpcm.h | 286 +++ .../ui/audio/call_notification_tone.h | 254 +++ .../platform/ui/reticulum_call_runtime.h | 25 + .../include/platform/ui/screen_runtime.h | 1 + .../include/platform/ui/wifi_access_runtime.h | 4 +- .../platform/ui/reticulum_call_runtime.cpp | 172 +- .../tests/test_app_config_defaults.cpp | 15 + .../tests/test_reticulum_call_runtime.cpp | 305 +++ .../chat/chat_workspace_snapshot.h | 1 + .../screens/chat/chat_conversation_styles.h | 2 +- .../screens/network/micron_markup_contract.h | 22 + .../ui/screens/settings/settings_state.h | 4 + .../ui/widgets/foreground_operation_overlay.h | 1 + .../ui/widgets/reticulum_ping_overlay.h | 25 + .../dashboard/dashboard_compass_widget.cpp | 4 + .../menu/dashboard/dashboard_gps_widget.cpp | 4 + .../menu/dashboard/dashboard_mesh_widget.cpp | 4 + .../dashboard/dashboard_recent_widget.cpp | 4 + .../src/ui/menu/dashboard/dashboard_style.cpp | 4 + .../chat_presentation_source.cpp | 1 + .../chat/chat_conversation_components.cpp | 3 +- .../screens/chat/chat_conversation_styles.cpp | 12 +- .../ui/screens/chat/chat_ui_controller.cpp | 13 +- .../chat_conversation_components_watch.cpp | 10 +- .../contacts/contacts_page_components.cpp | 18 +- .../network/micron_markup_contract.cpp | 161 +- .../ui/screens/network/network_page_shell.cpp | 601 ++++-- .../settings/settings_page_components.cpp | 37 + .../src/ui/screens/settings/settings_spec.cpp | 4 + .../src/ui/widgets/reticulum_call_overlay.cpp | 135 +- .../src/ui/widgets/reticulum_ping_overlay.cpp | 269 +++ .../ui/widgets/route_elevation_profile.cpp | 4 + .../src/ui/widgets/route_image_strip.cpp | 7 + .../tests/test_chat_presentation_source.cpp | 2 + .../test_network_micron_markup_contract.cpp | 53 + .../test_network_micron_renderer_fixture.cpp | 426 ++++ .../chat/infra/lxmf/lxmf_adapter.h | 53 +- .../chat/infra/lxmf/lxmf_delivery_runtime.h | 4 +- .../chat/infra/lxmf/lxmf_runtime_state.h | 27 + .../chat/infra/lxmf/lxmf_transport_runtime.h | 42 + .../chat/infra/mesh_adapter_router.h | 3 +- .../infra/reticulum/reticulum_interfaces.h | 26 +- .../chat/infra/store/sd_store.h | 7 +- .../esp/arduino_common/include/screen_sleep.h | 3 + .../arduino_common/include/sys/event_bus.h | 35 +- platform/esp/arduino_common/library.json | 3 + .../arduino_common/src/app_config_store.cpp | 29 +- .../src/app_event_runtime_support.cpp | 21 + .../src/app_runtime_support.cpp | 125 +- .../src/chat/infra/lxmf/lxmf_adapter.cpp | 1804 ++++++++++++++--- .../chat/infra/lxmf/lxmf_delivery_runtime.cpp | 4 +- .../src/chat/infra/lxmf/lxmf_identity.cpp | 6 +- .../infra/lxmf/lxmf_transport_runtime.cpp | 251 ++- .../infra/reticulum/reticulum_interfaces.cpp | 233 ++- .../src/chat/infra/rnode/rnode_adapter.cpp | 19 +- .../src/chat/infra/store/sd_store.cpp | 253 ++- ...latform_ui_reticulum_directory_runtime.cpp | 52 +- .../src/platform_ui_screen_runtime.cpp | 5 + .../src/platform_ui_wifi_access_runtime.cpp | 35 +- .../src/reticulum_call_audio_runtime.cpp | 404 +--- .../esp/arduino_common/src/screen_sleep.cpp | 5 + .../esp/common/reticulum_call_audio_engine.h | 22 + .../esp/common/reticulum_crypto_compat.h | 27 + .../esp/common/reticulum_runtime_compat.h | 32 + platform/esp/common/library.json | 19 + .../src/reticulum_call_audio_engine.cpp | 417 ++++ .../reticulum_call_runtime_support.h | 13 + .../platform/esp/idf_common/startup_support.h | 1 + .../esp/idf_common/usb_console_runtime.h | 9 + .../wireless_companion/c6_companion.h | 33 + .../idf_common/src/c6_companion_runtime.cpp | 278 ++- .../esp/idf_common/src/chat_blob_store_io.cpp | 284 +++ .../src/platform_ui_device_runtime.cpp | 12 +- ...latform_ui_reticulum_directory_runtime.cpp | 947 ++++++++- .../src/reticulum_call_runtime_support.cpp | 361 ++++ .../src/reticulum_crypto_compat.cpp | 150 ++ .../src/reticulum_runtime_compat.cpp | 50 + platform/esp/idf_common/src/screen_sleep.cpp | 13 + .../esp/idf_common/src/startup_support.cpp | 34 + .../idf_common/src/usb_console_runtime.cpp | 83 + .../t_display_p4/CMakeLists.txt | 3 + .../bsp/trail_mate_t_display_p4_runtime.h | 20 + .../t_display_p4_audio_runtime.cpp | 358 ++++ .../common/src/platform/ui/screen_runtime.cpp | 5 + .../src/platform_ui_screen_runtime.cpp | 4 + .../test_reticulum_runtime_state_contract.cpp | 128 +- ...est_reticulum_supported_subset_vectors.cpp | 103 + 122 files changed, 10954 insertions(+), 1046 deletions(-) create mode 100644 docs/reticulum/micron-support-corpus.md create mode 100644 docs/sound/call.mp3 create mode 100644 firmware/c6_companion/components/tm_wifi/include/tm_wifi_tcp.h create mode 100644 firmware/c6_companion/components/tm_wifi/tm_wifi_tcp.c create mode 100644 modules/core_chat/include/chat/infra/reticulum/lxst_telephony_wire.h create mode 100644 modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp create mode 100644 modules/core_sys/include/platform/ui/audio/call_notification_adpcm.h create mode 100644 modules/core_sys/include/platform/ui/audio/call_notification_tone.h create mode 100644 modules/core_sys/tests/test_app_config_defaults.cpp create mode 100644 modules/core_sys/tests/test_reticulum_call_runtime.cpp create mode 100644 modules/ui_shared/include/ui/widgets/reticulum_ping_overlay.h create mode 100644 modules/ui_shared/src/ui/widgets/reticulum_ping_overlay.cpp create mode 100644 modules/ui_shared/tests/test_network_micron_renderer_fixture.cpp create mode 100644 platform/esp/common/include/platform/esp/common/reticulum_call_audio_engine.h create mode 100644 platform/esp/common/include/platform/esp/common/reticulum_crypto_compat.h create mode 100644 platform/esp/common/include/platform/esp/common/reticulum_runtime_compat.h create mode 100644 platform/esp/common/library.json create mode 100644 platform/esp/common/src/reticulum_call_audio_engine.cpp create mode 100644 platform/esp/idf_common/include/platform/esp/idf_common/reticulum_call_runtime_support.h create mode 100644 platform/esp/idf_common/include/platform/esp/idf_common/usb_console_runtime.h create mode 100644 platform/esp/idf_common/src/chat_blob_store_io.cpp create mode 100644 platform/esp/idf_common/src/reticulum_call_runtime_support.cpp create mode 100644 platform/esp/idf_common/src/reticulum_crypto_compat.cpp create mode 100644 platform/esp/idf_common/src/reticulum_runtime_compat.cpp create mode 100644 platform/esp/idf_common/src/usb_console_runtime.cpp create mode 100644 platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0455e11ba74f721f8caeeebc6aa83671566dd68d..0784ea881a720a3b65ea15a5eedcc53a4b57c0d1 100644 GIT binary patch delta 2182 zcmaJ?e{54#6h5yTcBvhMZIqRtF9Tg!_M<34AqE8n)Umg9T^P-jt?S;(Y`2b%zHXRM zCQV=&OE+)ATO$5|NK_IHsKF2uL=z(cA@YY&LP8c368|79Bt!#>^?dhHCXy^o``*3x z-0ytnobR0Oohtb3e8Gu~=|!vAr@pxwSL$*I?%%o7Z7pu2zfyz#W_g{ zPMOYvf|!s#R2hQl;hiYyrf*UyN%cv^8(agOl5Eh$29Ql1)w6K0?uM~UvrYqi73Tnk zbUIj0Dm}%|1Y1E?9$K#Hk*U!YvX&^o3H`locUrC8zgzG6(#ACUls5X!p8fhC+SSaK z1OIH@9M=!*@XglJ6X%B0#=X7oG;vU}#r#Yo%p&QQgk%FSiKujM(L$4U3SWs^y!~Ub zJ;|0VG_f<^Ek1ZPzoefD^EvNYZH)#xlIMOLNKyXuQIl zMPTEs?H`A(X2hi3<&nzGp6K_qO!=>Cm;K6^%B9)H1=_@Z4)ny@4>luz;_RXY&mkCR z*4Uo(*#nE4^a!#&6V1d#huP3`ZWzfHV}a5l4q-kVZ6EFaH+W)AJTcHV!e%I2w4X?(&zW+}p%oz%&Ew_)q(!##Jfbxy~bk=_&( zGd<87W$xd25YJ2I`tOtyQ=O&cI|nCj8mINS%}^MI<{E1%S@R`VDzr_KOBI(z?vgsU zWM{P%#%Q(Kc1LmZ%oES%Vj+Iw@4(Wp%D`wQsKv6jCN1)hog^b* zvPxz1bA~?(KJ89PXc(i}b0fQ--*qO&29gj?KoF#+3vJOwT{f{bdb@+ILjR*?<>%3R z7D-dRp*)tyfr#->=QmCJ0RzZ9%IFZ*PR^m_8KI$3FrJV{;8;GAyo$1a3@iP#x7oke1a&8#%Kx=dQNv zN`^G41uboy^AnFSo3}i2DN_+R)q1BjcluLp=&r9Q`0cPW+os{z&g+{-ebYtl`?fbH gKm>%+24?!&mROfqS5dalQNG_%u6*Svw;A*P1)VD%mH+?% delta 5353 zcmbVQe@vX^9e*Bd%ddk{;11})`Vy_f$nDO$RfEf%Ep=1r3&))xgF!hsepr4K?ng^& zdToc3(pI34_9ZU1HF2q#iLHytrZ$~TR?V#1n9OC#Vj8DTXI!fLgK2BMeLnBY%K>XU zw_M=8d+z&upYM;)_w)Te_uT6lyDns$*mj+Y21?bvH{=^L+oj7B5t?YnUzg|=!y+Q0 z!Xr9FAJ!sxX<`dj2SuldVKr*(-XWbU+VS?vq9SK@k^WsM16Y_STz6wnIlJRvMwjBksn3O$5byQHN(YB%;`{ z70(fT?!a>xuU=D2L{u8@jmsh<=#u$~POL^?D2*Nwm5{y*nrY#P=*Kf=I#P$7mIWQ= zcTv-^K2u)DH&L85h_%hw-wthC@C-u-^9ZK#1iOdZRr5n_Wqy31!((+vu5A0Wk6zaAZ>buQ zzH6ubSjXCW>06xY$6705VgLI9>0^2|k!TNG`Ixx`LuljF0OG{>5FZSE)J*C(PB?VD zHu1rLBl)5JI?f9M2JJ{^pU_lJPPRV!+*dnUd_tSx+Y;>puB z*paF(D|l(XEMS$D&}mla=i6n8(*k~%nbv-69Dw0rb^KIOI#r?=i9X{-pUl6>1Ak%w zZUk6}LQgj~^-&YVH1jn24EU;{)*|Ef$7EqSH4xD;{D)qMZGl#DN-tKrg;(9DN{kQ2 zWxnzCe(7+sDp=wR)!hke$U<;U`A?N(&K0XVKHxM;Gt;t+i-ye0_Ckh1n??QY^oQC= z5v&eU0#|xr5lv<#g(;2o`SqaQ$Sf|U$fB@d%hRFdJ-Pb9oMJO14c|B<*!uIme~LSj zO9P*VtjI2m%lmsLyS<{kXa5t@XMA=*eyhB(dC#5|!E;m6H*Cf)RonAo(K#0j)9sZTZxMQ>=fmzapPCG~4DGklRYE#t<`~s&_z&&E52yy!QvtWp*(8fW z`NeAgvQpLAUaSg_c-w{C*l!W>YuA`FslA0U8*!ABay9-#y=fCyFIt-k?>qhp@W2K zVJ5SK6#Lkrmq$k9r}hmbMy93jj9$^WKss0vn+aRgn+mfgKKyA*ZM-$&|HtEYG*g7m zs){JjlFY=l!uIFGPw)GFU9Nt5;u1JDm4Ix$X6)m>u)ng~*GO8jW3f-4(WNid*26u& z?C6_#J7A;J;#K;Q-gH^_iqNK<^d=UF3ARFWSWszO^VRs-EMqt)&2=4epvv?88zXzn=w|HjuP!j6pU6B} zsaBL0Ej5J<_pmH8zB(uK)XD6^BBnfwjZ|x?+6;vw#?U9SAj@WribnO>o31Q2Q>*_S z+*(N6v!2?qENUeXjkNem7MFXGHR;OkE3_6n8-zp<{YmYewTErKVB}qg$2x+tpmx?#I0ftx=#qg zqz#fEw`(Y!>e)Js7{}j{t5oL?3KJ5!m82WEQqb;5I)XFdGy1ZY zkFfh%ZdfzZL;e?=9}f++rTKD%)o^(tr#c2>0IIj+Gl#_*(SQlC2Do-6QXErf(iLZK z29Qi*4_|!GF_|1WXnvEba+`6;PBwFKtr|MwkZ1ShXS1i9I#W!J>{Y-pt3k87TYQQL zn3;*-Fwz?{*<+6Tw~_s~V41=eiE5@*T>uvQt;K^R-mvLK+&njGOr4R=OimHQM4(ER zl_AxnQ5G@>0uHWh%A|YF78+`<7l&B^TR%s+3r99DihBfxur0CWSyMm9j_6!tU)C}g za7Y9o8O5ttdGaz7Z%+MwXB8&Yg`rQ8r1Yi2E`6z>TNF1xAD9gV(@2!X|4r; z3_{3G1MxuQ5evL}u}dw0(|{|4x_Rt+RX65RZ#gs6SJN3~iF-A@y@us-p!1sekbr^A0cUyqPl)>IRH=k=O9GNO|>2d zJ!Gsvd%i8iLR{b&jyqC+c^3oTQWx4hj-h_OQ!ac?x%5KU8ly^&Pp@m?5SKYQGVM;z zNgIDxGAHXqq@wYR&O0}5A1f=bVlV8}#Ay6s|HqSun@>bu-MMEECZ@V%@rrdYJ!ZOL zjk-PR`CFXoq&rX5opTv~eO4B3AaDjv6tl4q_pGk@1Gmk=_Q=i8oDSFti2iO-sXX0f z**f0$TZ6eVbwU=ESs`S8`ST6`v+1)m(J9pVOK$bXOh#Y>n1Vm#*y`C>*b1y?``;%pq7f;I&%JFw7rYu4)jW-f9?=JcigAlt04<_h%Tjj)Ib(XuJj6YiOGdtQk zTwF90PlLPys?YBz*kGCFSr8SAt`=&y+bA^+BNjHnnr9ice~`{<>ju+iM#G*+Nh9oq zR<3NElUle&pX`b9Ej`e-v0mxtikmfYv0(tBMIuS}g7NuUSO!C)=sCGe zA2_BvlDv&vJ%H5gfg5&W_(O!5ko&Ke5nN_LXCYh;nv6DIJ!|l4L9K5SIy-nOkUu-R Q{E?%} +#include #include #include #include #include #include #include +#include #include #endif @@ -70,6 +77,8 @@ constexpr const char* kIdfNodesNvsKey = "nodes_blob"; constexpr const char* kIdfContactsNvsKey = "contacts"; constexpr const char* kIdfContactsFile = "/contacts.dat"; constexpr const char* kIdfNodesFile = "/nodes.bin"; +constexpr const char* kIdfMeshPeersDir = "/mesh"; +constexpr const char* kIdfMeshPeersFile = "/mesh/peers.bin"; constexpr size_t kIdfReadChunkBytes = 256; constexpr uint32_t kIdfAppConfigMagic = 0x50344346UL; // P4CF constexpr uint16_t kIdfAppConfigVersion = 1; @@ -81,6 +90,7 @@ constexpr size_t kIdfMaxNodeFileBytes = sizeof(chat::contacts::NodeStoreSdHeader) + chat::contacts::NodeStoreCore::kMaxNodes * chat::contacts::NodeStoreCore::kSerializedEntrySize; +constexpr size_t kIdfMaxMeshPeerBlobBytes = 768U * 1024U; constexpr const char* kIdfTeamTag = "idf-team"; constexpr size_t kTeamAeadTagBytes = 16; constexpr size_t kTeamAeadKeyBytes = 32; @@ -124,7 +134,8 @@ int8_t clampTxPower(int8_t value) bool idfSupportsMeshProtocol(chat::MeshProtocol protocol) { - return protocol == chat::MeshProtocol::Meshtastic; + return protocol == chat::MeshProtocol::Reticulum || + protocol == chat::MeshProtocol::Meshtastic; } void syncReticulumGroupConfig(app::AppConfig& config) @@ -152,9 +163,9 @@ void normalizeIdfAppConfig(app::AppConfig& config) !idfSupportsMeshProtocol(config.mesh_protocol)) { ESP_LOGW(kIdfConfigTag, - "unsupported mesh protocol=%u; falling back to Meshtastic", + "unsupported mesh protocol=%u; falling back to Reticulum", static_cast(config.mesh_protocol)); - config.mesh_protocol = chat::MeshProtocol::Meshtastic; + config.mesh_protocol = chat::MeshProtocol::Reticulum; } if (chat::meshtastic::findRegion( @@ -640,6 +651,87 @@ class IdfSdContactBlobStore final : public chat::IContactBlobStore } }; +class IdfSdMeshPeerDirectoryBlobStore final + : public chat::IMeshPeerDirectoryBlobStore +{ + public: + chat::MeshPeerDirectoryBlobLoadResult loadBlob( + std::vector& out) override + { + out.clear(); + if (!platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready()) + { + return chat::MeshPeerDirectoryBlobLoadResult::Unavailable; + } + + const std::string path = makeSdPath(kIdfMeshPeersFile); + struct stat info = {}; + if (::stat(path.c_str(), &info) != 0) + { + return errno == ENOENT + ? chat::MeshPeerDirectoryBlobLoadResult::Missing + : chat::MeshPeerDirectoryBlobLoadResult::IoError; + } + if (!S_ISREG(info.st_mode) || info.st_size <= 0 || + static_cast(info.st_size) > kIdfMaxMeshPeerBlobBytes) + { + return chat::MeshPeerDirectoryBlobLoadResult::IoError; + } + if (!readSdFile(kIdfMeshPeersFile, out, kIdfMaxMeshPeerBlobBytes)) + { + return chat::MeshPeerDirectoryBlobLoadResult::IoError; + } + + ESP_LOGI(kIdfStoreTag, + "mesh peer load path=%s len=%u", + kIdfMeshPeersFile, + static_cast(out.size())); + return chat::MeshPeerDirectoryBlobLoadResult::Loaded; + } + + bool saveBlob(const uint8_t* data, size_t len) override + { + if ((!data && len != 0) || len > kIdfMaxMeshPeerBlobBytes || + !ensureDirectory()) + { + return false; + } + const bool ok = writeSdFileAtomic(kIdfMeshPeersFile, data, len); + ESP_LOGI(kIdfStoreTag, + "mesh peer save path=%s len=%u ok=%u", + kIdfMeshPeersFile, + static_cast(len), + ok ? 1U : 0U); + return ok; + } + + void clearBlob() override + { + (void)removeSdFile(kIdfMeshPeersFile); + } + + private: + static bool ensureDirectory() + { + if (!platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready()) + { + return false; + } + + const std::string path = makeSdPath(kIdfMeshPeersDir); + struct stat info = {}; + if (::stat(path.c_str(), &info) == 0) + { + return S_ISDIR(info.st_mode); + } + if (::mkdir(path.c_str(), 0775) == 0) + { + return true; + } + return ::stat(path.c_str(), &info) == 0 && S_ISDIR(info.st_mode); + } +}; + class IdfNullMeshAdapter final : public chat::IMeshAdapter { public: @@ -1070,6 +1162,17 @@ class IdfAppFacadeRuntime final : public app::IAppFacade } syncReticulumGroupConfig(config_); + mesh_peer_directory_.setAutoSaveEnabled(false); + const chat::MeshPeerDirectoryStatus peer_directory_status = + mesh_peer_directory_.begin(); + mesh_peer_directory_ready_ = peer_directory_status.succeeded(); + platform::ui::reticulum_directory::bind_mesh_peer_directory( + mesh_peer_directory_ready_ ? &mesh_peer_directory_ : nullptr); + ESP_LOGI(kIdfStoreTag, + "mesh peer directory path=%s status=%u", + kIdfMeshPeersFile, + static_cast(peer_directory_status.code)); + if (!sys::EventBus::init()) { return false; @@ -1085,7 +1188,13 @@ class IdfAppFacadeRuntime final : public app::IAppFacade copyString(config_.short_name, sizeof(config_.short_name), identity.short_name); } - installMeshAdapter(); + if (!installMeshAdapter()) + { + ESP_LOGE(kIdfConfigTag, + "mesh backend install failed proto=%u", + static_cast(config_.mesh_protocol)); + return false; + } applyMeshConfig(); applyUserInfo(); applyNetworkLimits(); @@ -1126,7 +1235,21 @@ class IdfAppFacadeRuntime final : public app::IAppFacade { normalizeIdfAppConfig(config_); syncReticulumGroupConfig(config_); - meshAdapter().applyConfig(config_.activeMeshConfig()); + if (!mesh_router_.hasBackend() || + mesh_router_.backendProtocol() != config_.mesh_protocol) + { + if (!switchMeshProtocol(config_.mesh_protocol, false)) + { + ESP_LOGE(kIdfConfigTag, + "mesh backend switch failed proto=%u", + static_cast(config_.mesh_protocol)); + return; + } + } + else + { + meshAdapter().applyConfig(config_.activeMeshConfig()); + } if (board_) { board_->applyRadioConfig(config_.mesh_protocol, config_.activeMeshConfig()); @@ -1183,8 +1306,10 @@ class IdfAppFacadeRuntime final : public app::IAppFacade bool switchMeshProtocol(chat::MeshProtocol protocol, bool persist = true) override { - if (!chat::infra::isValidMeshProtocol(protocol) || - !idfSupportsMeshProtocol(protocol)) + const chat::MeshProtocol normalized = + chat::infra::normalizeMeshProtocol(protocol); + if (!chat::infra::isValidMeshProtocol(normalized) || + !idfSupportsMeshProtocol(normalized)) { ESP_LOGW(kIdfConfigTag, "reject mesh protocol switch proto=%u", @@ -1192,12 +1317,43 @@ class IdfAppFacadeRuntime final : public app::IAppFacade return false; } - config_.mesh_protocol = protocol; + std::unique_ptr backend = + createMeshBackend(normalized); + if (!backend) + { + return false; + } + + const chat::MeshProtocol previous_protocol = config_.mesh_protocol; + config_.mesh_protocol = normalized; + syncReticulumGroupConfig(config_); + backend->applyConfig(config_.activeMeshConfig()); + + char long_name[sizeof(config_.node_name)] = {}; + char short_name[sizeof(config_.short_name)] = {}; + getEffectiveUserInfo(long_name, + sizeof(long_name), + short_name, + sizeof(short_name)); + backend->setUserInfo(long_name, short_name); + backend->setNetworkLimits(config_.net_duty_cycle, config_.net_channel_util); + backend->setPrivacyConfig(config_.privacy_encrypt_mode); + + if (!mesh_router_.installBackend(normalized, std::move(backend))) + { + config_.mesh_protocol = previous_protocol; + return false; + } + mesh_adapter_ = &mesh_router_; + + if (board_) + { + board_->applyRadioConfig(normalized, config_.activeMeshConfig()); + } if (chat_service_) { - chat_service_->setActiveProtocol(protocol); + chat_service_->setActiveProtocol(normalized); } - applyMeshConfig(); if (persist) { saveConfig(); @@ -1424,16 +1580,43 @@ class IdfAppFacadeRuntime final : public app::IAppFacade } } - void installMeshAdapter() + std::unique_ptr createMeshBackend( + chat::MeshProtocol protocol) + { + if (lora_board_ == nullptr) + { + return nullptr; + } + + if (protocol == chat::MeshProtocol::Reticulum) + { + return std::unique_ptr( + new chat::reticulum::ReticulumAdapter( + *lora_board_, + mesh_peer_directory_ready_ ? &mesh_peer_directory_ : nullptr)); + } + if (protocol == chat::MeshProtocol::Meshtastic) + { + return std::unique_ptr( + new platform::esp::radio::MeshtasticRadioAdapter(*lora_board_)); + } + return nullptr; + } + + bool installMeshAdapter() { null_mesh_adapter_.setSelfNodeId(resolveSelfNodeId()); mesh_adapter_ = &null_mesh_adapter_; - if (lora_board_ != nullptr) + std::unique_ptr backend = + createMeshBackend(config_.mesh_protocol); + if (!backend || + !mesh_router_.installBackend(config_.mesh_protocol, std::move(backend))) { - radio_mesh_adapter_.reset(new platform::esp::radio::MeshtasticRadioAdapter(*lora_board_)); - mesh_adapter_ = radio_mesh_adapter_.get(); + return false; } + mesh_adapter_ = &mesh_router_; + return true; } chat::IMeshAdapter& meshAdapter() @@ -1462,6 +1645,11 @@ class IdfAppFacadeRuntime final : public app::IAppFacade { ESP_LOGW(kIdfStoreTag, "node flush failed"); } + if (mesh_peer_directory_ready_ && + !mesh_peer_directory_.flush().succeeded()) + { + ESP_LOGW(kIdfStoreTag, "mesh peer directory flush failed"); + } } static bool isTeamRuntimeEvent(sys::EventType type) @@ -1595,13 +1783,15 @@ class IdfAppFacadeRuntime final : public app::IAppFacade app::AppConfig config_{}; IdfSdNodeBlobStore node_blob_store_{}; IdfSdContactBlobStore contact_blob_store_{}; + IdfSdMeshPeerDirectoryBlobStore mesh_peer_directory_blob_store_{}; chat::contacts::NodeStoreCore node_store_{node_blob_store_}; chat::contacts::ContactStoreCore contact_store_{contact_blob_store_}; + chat::MeshPeerDirectoryCore mesh_peer_directory_{mesh_peer_directory_blob_store_}; chat::contacts::ContactService contact_service_{node_store_, contact_store_}; chat::ChatModel chat_model_{}; chat::RamStore chat_store_{}; IdfNullMeshAdapter null_mesh_adapter_{}; - std::unique_ptr radio_mesh_adapter_{}; + chat::MeshAdapterRouter mesh_router_{}; chat::IMeshAdapter* mesh_adapter_ = &null_mesh_adapter_; std::unique_ptr chat_service_{}; std::unique_ptr team_crypto_{}; @@ -1614,6 +1804,7 @@ class IdfAppFacadeRuntime final : public app::IAppFacade std::unique_ptr team_controller_{}; std::unique_ptr team_track_sampler_{}; chat::ui::IChatUiRuntime* chat_ui_runtime_ = nullptr; + bool mesh_peer_directory_ready_ = false; uint32_t last_node_store_flush_ms_ = 0; }; @@ -1649,7 +1840,7 @@ bool initialize(const platform::esp::boards::AppContextInitHandles& handles, config.target_name, static_cast(s_runtime.getSelfNodeId()), s_runtime.getMeshAdapter() != nullptr && s_runtime.getMeshAdapter()->isReady() - ? "meshtastic_radio" + ? chat::infra::meshProtocolName(s_runtime.getMeshProtocol()) : "not_ready"); return true; #else diff --git a/apps/esp32_lvgl/src/esp32_lvgl_startup_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_startup_runtime.cpp index 0da19cec..175c1a0e 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_startup_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_startup_runtime.cpp @@ -11,7 +11,9 @@ #include "platform/esp/boards/board_runtime.h" #include "platform/esp/idf_common/bsp_runtime.h" #include "platform/esp/idf_common/debug/sd_coredump_export.h" +#include "platform/esp/idf_common/reticulum_call_runtime_support.h" #include "platform/esp/idf_common/startup_support.h" +#include "platform/esp/idf_common/usb_console_runtime.h" #include "platform/esp/idf_common/wireless_companion/c6_companion.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" @@ -150,6 +152,8 @@ void runEsp32LvglStartupRuntime(const Esp32LvglRuntimeConfig& config) #if defined(ESP_PLATFORM) constexpr bool waking_from_sleep = false; + (void)platform::esp::idf_common::usb_console::ensure_started(); + platform::esp::idf_common::startup_support::initializeClockProviders(); platform::esp::idf_common::startup_support::logStartupBanner(config.log_tag); (void)platform::esp::idf_common::bsp_runtime::ensure_nvs_ready(); platform::esp::boards::initializeBoard(waking_from_sleep); @@ -180,6 +184,7 @@ void runEsp32LvglStartupRuntime(const Esp32LvglRuntimeConfig& config) } idf_app_runtime_access::initialize(config); + platform::esp::idf_common::reticulum_call_support::ensure_registered(); applyPlatformRuntimeConfig(config); const ui::startup_shell::Hooks shell_hooks = buildShellHooks(config); diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index d04f9b33..45ca09b0 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -326,6 +326,7 @@ if(BUILD_TESTING) add_executable(trailmate_reticulum_supported_subset_vectors_smoke "${TRAIL_MATE_REPO_ROOT}/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp") target_include_directories(trailmate_reticulum_supported_subset_vectors_smoke PRIVATE @@ -562,6 +563,35 @@ if(BUILD_TESTING) add_test(NAME trailmate_runtime_async_smoke COMMAND trailmate_runtime_async_smoke) + add_executable(trailmate_app_config_defaults_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_app_config_defaults.cpp") + target_include_directories(trailmate_app_config_defaults_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_definitions(trailmate_app_config_defaults_smoke + PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) + target_compile_features(trailmate_app_config_defaults_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_app_config_defaults_smoke + COMMAND trailmate_app_config_defaults_smoke) + + # MinGW.org's libstdc++ is built without gthreads and does not expose + # std::mutex. Linux and modern host toolchains exercise this runtime test. + if(NOT MINGW) + add_executable(trailmate_reticulum_call_runtime_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_reticulum_call_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp") + target_include_directories(trailmate_reticulum_call_runtime_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_reticulum_call_runtime_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_reticulum_call_runtime_smoke + COMMAND trailmate_reticulum_call_runtime_smoke) + endif() + add_executable(trailmate_storage_event_runtime_smoke "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_storage_event_runtime.cpp") target_include_directories(trailmate_storage_event_runtime_smoke @@ -645,6 +675,17 @@ if(BUILD_TESTING) add_test(NAME trailmate_network_micron_markup_contract_smoke COMMAND trailmate_network_micron_markup_contract_smoke) + add_executable(trailmate_network_micron_renderer_fixture_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_network_micron_renderer_fixture.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/network/micron_markup_contract.cpp") + target_include_directories(trailmate_network_micron_renderer_fixture_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include") + target_compile_features(trailmate_network_micron_renderer_fixture_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_network_micron_renderer_fixture_smoke + COMMAND trailmate_network_micron_renderer_fixture_smoke) + add_executable(trailmate_pinyin_lookup_smoke "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_pinyin_lookup.cpp") target_include_directories(trailmate_pinyin_lookup_smoke diff --git a/apps/linux_sim_shell/tests/linux_reticulum_directory_runtime_smoke.cpp b/apps/linux_sim_shell/tests/linux_reticulum_directory_runtime_smoke.cpp index 3b65ab76..fbd8422b 100644 --- a/apps/linux_sim_shell/tests/linux_reticulum_directory_runtime_smoke.cpp +++ b/apps/linux_sim_shell/tests/linux_reticulum_directory_runtime_smoke.cpp @@ -111,6 +111,48 @@ int main() rtdir::bind_mesh_peer_directory(nullptr); assert(std::strcmp(rtdir::lxmf_addresses_path(), "/mesh/peers.bin") == 0); + rtdir::AnnounceRecord announce{}; + announce.valid = true; + for (std::size_t index = 0; index < rtdir::kReticulumHashSize; ++index) + { + announce.destination_hash[index] = static_cast(0x10U + index); + announce.identity_hash[index] = static_cast(0x30U + index); + } + announce.aspect = rtdir::AnnounceAspect::LxmfDelivery; + announce.source = rtdir::EntrySource::RuntimeRx; + announce.first_seen_s = 10; + announce.last_seen_s = 20; + announce.hops = 2; + announce.delivery = true; + std::snprintf(announce.display_name, + sizeof(announce.display_name), + "%s", + "first announce"); + assert(rtdir::record_announce(announce).saved); + + announce.first_seen_s = 30; + announce.last_seen_s = 40; + announce.hops = 1; + std::snprintf(announce.display_name, + sizeof(announce.display_name), + "%s", + "updated announce"); + assert(rtdir::record_announce(announce).saved); + + rtdir::AnnounceRecord loaded_announces[2] = {}; + std::size_t loaded_announce_count = 0; + const rtdir::Status announce_load_status = + rtdir::load_announces(loaded_announces, + 2, + &loaded_announce_count); + assert(announce_load_status.loaded); + assert(loaded_announce_count == 1); + assert(loaded_announces[0].first_seen_s == 10); + assert(loaded_announces[0].last_seen_s == 40); + assert(loaded_announces[0].hops == 1); + assert(std::strcmp(loaded_announces[0].display_name, + "updated announce") == 0); + rtdir::LxmfAddressRecord alpha = make_address("alpha trail", true, true); char alpha_destination[rtpage::kReticulumPageDestinationTextSize] = {}; format_hash_hex(alpha.destination_hash, diff --git a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h index 3bd10625..e517b7ba 100644 --- a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h +++ b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h @@ -231,6 +231,7 @@ class TLoRaPagerBoard : public BoardBase, * @brief Play incoming-message prompt tone */ void playMessageTone() override; + void playIncomingCallTone(const volatile bool* stop_requested = nullptr); void setMessageToneVolume(uint8_t volume_percent) override; uint8_t getMessageToneVolume() const override; diff --git a/boards/tlora_pager/src/tlora_pager_board.cpp b/boards/tlora_pager/src/tlora_pager_board.cpp index 435e437e..edcb14eb 100644 --- a/boards/tlora_pager/src/tlora_pager_board.cpp +++ b/boards/tlora_pager/src/tlora_pager_board.cpp @@ -19,6 +19,7 @@ #include "pins_arduino.h" #include "platform/esp/arduino_common/power/battery_adc.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/ui/audio/call_notification_tone.h" #include "platform/ui/audio/pager_notification_tone.h" #include "platform/ui/settings_store.h" #include "ui/runtime/ui_feedback.h" @@ -134,6 +135,20 @@ bool withSharedSpiRadioAccess(LilyGoDispArduinoSPI& spi, return true; } +template +uint16_t fillMonoPlayback(PlaybackState& state, + int16_t* pcm, + uint16_t max_frames, + NextSample next_sample) +{ + uint16_t frames = 0; + while (frames < max_frames && next_sample(state, pcm[frames])) + { + ++frames; + } + return frames; +} + #ifdef USING_XL9555_EXPANDS #define BOSCH_BHI260_KLIO #else @@ -1132,6 +1147,7 @@ void TLoRaPagerBoard::playMessageTone() s_last_play_ms = now; static constexpr size_t kFramesPerChunk = 128; + static constexpr uint8_t kCodecChannels = 1; namespace pager_tone = ::platform::ui::audio::pager_notification; int prev_volume = codec.getVolume(); @@ -1141,32 +1157,139 @@ void TLoRaPagerBoard::playMessageTone() } bool prev_out_mute = codec.getOutMute(); - bool opened = - (codec.open(16, pager_tone::kChannels, pager_tone::kPlaybackSampleRateHz) == 0); - if (!opened) + const int open_result = + codec.open(16, kCodecChannels, pager_tone::kPlaybackSampleRateHz); + if (open_result != 0) { + Serial.printf("[Audio][PagerTone] open failed kind=message rc=%d rate=%lu channels=%u\n", + open_result, + static_cast(pager_tone::kPlaybackSampleRateHz), + static_cast(kCodecChannels)); s_playing = false; return; } codec.setOutMute(false); codec.setVolume(_message_tone_volume); + delay(10); - int16_t pcm[kFramesPerChunk * pager_tone::kChannels]; + int16_t pcm[kFramesPerChunk]; pager_tone::AdpcmPlaybackState tone_state{}; while (pager_tone::hasMore(tone_state)) { - const uint16_t frames = pager_tone::fillStereoInterleaved( - tone_state, pcm, static_cast(kFramesPerChunk)); + const uint16_t frames = fillMonoPlayback(tone_state, + pcm, + static_cast(kFramesPerChunk), + pager_tone::nextPlaybackSample); if (frames == 0) { break; } - codec.write(reinterpret_cast(pcm), - frames * pager_tone::kChannels * sizeof(int16_t)); - delay(1); + const int write_result = codec.write(reinterpret_cast(pcm), + frames * sizeof(int16_t)); + if (write_result != 0) + { + Serial.printf("[Audio][PagerTone] write failed kind=message rc=%d frames=%u\n", + write_result, + static_cast(frames)); + break; + } } + delay(5); + codec.setVolume(static_cast(prev_volume)); + codec.setOutMute(prev_out_mute); + codec.close(); + s_playing = false; +#endif +} + +void TLoRaPagerBoard::playIncomingCallTone(const volatile bool* stop_requested) +{ +#ifndef USING_AUDIO_CODEC + return; +#else + if ((stop_requested && *stop_requested) || _message_tone_volume == 0) + { + return; + } + if (!(devices_probe & HW_CODEC_ONLINE)) + { + return; + } + + static bool s_playing = false; + static uint32_t s_last_play_ms = 0; + + if (s_playing) + { + return; + } + + const uint32_t now = millis(); + if ((now - s_last_play_ms) < 240) + { + return; + } + + s_playing = true; + s_last_play_ms = now; + + static constexpr size_t kFramesPerChunk = 128; + static constexpr uint8_t kCodecChannels = 1; + namespace call_tone = ::platform::ui::audio::call_notification; + + int prev_volume = codec.getVolume(); + if (prev_volume < 0 || prev_volume > 100) + { + prev_volume = 50; + } + bool prev_out_mute = codec.getOutMute(); + + const int open_result = + codec.open(16, kCodecChannels, call_tone::kPlaybackSampleRateHz); + if (open_result != 0) + { + Serial.printf("[Audio][PagerTone] open failed kind=call rc=%d rate=%lu channels=%u\n", + open_result, + static_cast(call_tone::kPlaybackSampleRateHz), + static_cast(kCodecChannels)); + s_playing = false; + return; + } + + codec.setOutMute(false); + codec.setVolume(_message_tone_volume); + delay(10); + + int16_t pcm[kFramesPerChunk]; + call_tone::AdpcmPlaybackState tone_state{}; + while (call_tone::hasMore(tone_state)) + { + if (stop_requested && *stop_requested) + { + break; + } + const uint16_t frames = fillMonoPlayback(tone_state, + pcm, + static_cast(kFramesPerChunk), + call_tone::nextPlaybackSample); + if (frames == 0) + { + break; + } + const int write_result = codec.write(reinterpret_cast(pcm), + frames * sizeof(int16_t)); + if (write_result != 0) + { + Serial.printf("[Audio][PagerTone] write failed kind=call rc=%d frames=%u\n", + write_result, + static_cast(frames)); + break; + } + } + + delay(5); codec.setVolume(static_cast(prev_volume)); codec.setOutMute(prev_out_mute); codec.close(); diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index daed065f..ba09da08 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -5,6 +5,7 @@ # this file. set(TRAILMATE_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") +include("${TRAILMATE_ROOT}/third_party/codec2/codec2_sources.cmake") set(TRAILMATE_ESP_IDF_APP_SHELL_SOURCES "${TRAILMATE_ROOT}/apps/esp32_lvgl/src/esp32_lvgl_idf_app_registry.cpp" @@ -54,6 +55,7 @@ set(TRAILMATE_ESP_IDF_CORE_CHAT_SOURCES "${TRAILMATE_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/delivery/chat_delivery_send_result_projection.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/contact_store_core.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/mesh_adapter_router_core.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/mesh_protocol_utils.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/meshcore/mc_region_presets.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/meshcore/meshcore_identity_crypto.cpp" @@ -78,7 +80,12 @@ set(TRAILMATE_ESP_IDF_CORE_CHAT_SOURCES "${TRAILMATE_ROOT}/modules/core_chat/src/infra/meshtastic/mt_region.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/node_store_blob_format.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/node_store_core.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/mesh_peer_directory_core.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/reticulum/audio_call_wire.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/rnode/rnode_packet_wire.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/runtime/meshtastic_position_core.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/runtime/meshtastic_self_announcement_core.cpp" @@ -315,7 +322,9 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/ime/pinyin_ime.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/progress_overlay_presenter.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/reticulum_ping_overlay.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/route_image_strip.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/route_image_operation_presenter.cpp" @@ -375,6 +384,7 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/ble_manager_stub.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/bsp_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/c6_companion_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/idf_common/src/chat_blob_store_io.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/debug/sd_coredump_export.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/shared_spi_lock.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/gps_runtime.cpp" @@ -397,6 +407,9 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_usb_support_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_walkie_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/idf_common/src/reticulum_crypto_compat.cpp" + "${TRAILMATE_ROOT}/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp" + "${TRAILMATE_ROOT}/platform/esp/idf_common/src/reticulum_runtime_compat.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/startup_support.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/screen_sleep.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/sx126x_radio.cpp" @@ -405,17 +418,41 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/ui_common.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/ui_dispatcher.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/usb_cdc_transport.cpp" + "${TRAILMATE_ROOT}/platform/esp/idf_common/src/usb_console_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/walkie_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_link_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_service_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_resource_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/sstv/cordic.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/sstv/decode_sstv.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/sstv/half_band_filter2.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/sstv/sstv_service.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/walkie/walkie_service.cpp" + "${TRAILMATE_ROOT}/platform/esp/common/src/reticulum_call_audio_engine.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_wireless_companion_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/radio/meshtastic_radio_adapter.cpp") +list(APPEND TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES + ${trail_mate_codec2_sources} + "${TRAILMATE_ROOT}/third_party/bzip2/src/bz_internal_error.c" + "${TRAILMATE_ROOT}/third_party/bzip2/src/bzlib.c" + "${TRAILMATE_ROOT}/third_party/bzip2/src/crctable.c" + "${TRAILMATE_ROOT}/third_party/bzip2/src/decompress.c" + "${TRAILMATE_ROOT}/third_party/bzip2/src/huffman.c" + "${TRAILMATE_ROOT}/third_party/bzip2/src/randtable.c") + set(TRAILMATE_ESP_IDF_TAB5_BOARD_SOURCES "${TRAILMATE_ROOT}/boards/tab5/src/codec_compat.cpp" "${TRAILMATE_ROOT}/boards/tab5/src/heading_runtime.cpp" @@ -434,6 +471,8 @@ set(TRAILMATE_ESP_IDF_FINAL_INCLUDE_DIRS "${TRAILMATE_ROOT}/modules/core_chat/include" "${TRAILMATE_ROOT}/modules/core_chat/generated" "${TRAILMATE_ROOT}/modules/core_chat/third_party/nanopb" + "${TRAILMATE_ROOT}/third_party/bzip2/src" + "${TRAILMATE_ROOT}/third_party/codec2/src" "${TRAILMATE_ROOT}/modules/core_device/include" "${TRAILMATE_ROOT}/modules/core_gps/include" "${TRAILMATE_ROOT}/modules/core_mesh/include" diff --git a/builds/esp_idf/main/CMakeLists.txt b/builds/esp_idf/main/CMakeLists.txt index 8b479987..90cc258e 100644 --- a/builds/esp_idf/main/CMakeLists.txt +++ b/builds/esp_idf/main/CMakeLists.txt @@ -33,9 +33,11 @@ idf_component_register( REQUIRES driver esp_app_format + esp_codec_dev esp_lcd esp_lvgl_port esp_serial_slave_link + esp_tinyusb esp_timer espcoredump fatfs diff --git a/builds/esp_idf/main/idf_component.yml b/builds/esp_idf/main/idf_component.yml index 23584b7d..c2d70274 100644 --- a/builds/esp_idf/main/idf_component.yml +++ b/builds/esp_idf/main/idf_component.yml @@ -1,2 +1,4 @@ dependencies: espressif/esp_serial_slave_link: "^1.0.1" + espressif/esp_tinyusb: + version: "1.7.6~2" diff --git a/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults b/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults index 034764db..1cb2aca3 100644 --- a/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults +++ b/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults @@ -11,7 +11,15 @@ CONFIG_CACHE_L2_CACHE_256KB=y CONFIG_CACHE_L2_CACHE_LINE_128B=y CONFIG_ESPTOOLPY_FLASHFREQ_120M=y CONFIG_IDF_EXPERIMENTAL_FEATURES=y -CONFIG_TINYUSB_MSC_ENABLED=y +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y +CONFIG_TINYUSB_RHPORT_HS=y +CONFIG_TINYUSB_TASK_STACK_SIZE=4096 +CONFIG_TINYUSB_TASK_AFFINITY_CPU1=y +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_DESC_MANUFACTURER_STRING="Trail Mate" +CONFIG_TINYUSB_DESC_PRODUCT_STRING="Trail Mate T-Display-P4" +CONFIG_TINYUSB_DESC_CDC_STRING="Trail Mate Console" CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_TASK_WDT_TIMEOUT_S=20 CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 9b7a12d9..66672470 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -494,7 +494,10 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/ime/ime_input_mode_descriptor.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/ime/ime_widget.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/busy_overlay.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/foreground_operation_overlay.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/map/map_viewport.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/progress_overlay_presenter.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/reticulum_ping_overlay.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/system_notification.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/text_candidate_data.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/text_candidate_picker.cpp" diff --git a/dependencies.lock b/dependencies.lock index 04d76dd5..305ab9a0 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -113,6 +113,36 @@ dependencies: registry_url: https://components.espressif.com/ type: service version: 1.1.2 + espressif/esp_tinyusb: + component_hash: 6a50305bc61c7a361da8c0833642be824e92dacb0a6001719a832a4e96e471bf + dependencies: + - name: idf + require: private + version: '>=5.0' + - name: espressif/tinyusb + registry_url: https://components.espressif.com + require: public + version: '>=0.14.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.7.6~2 + espressif/tinyusb: + component_hash: a40b7f67df6ac254a4afd96c6401cc56f2059ae747d6d7e66b568bca53ad0edc + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - esp32s31 + version: 0.19.0~3 espressif/usb_host_hid: component_hash: 99d9ec2c6799ec4c8b366af5271b6e50700f6de3f5258e157de2b6799639cf66 dependencies: @@ -139,7 +169,7 @@ dependencies: idf: source: type: idf - version: 5.5.3 + version: 5.5.4 lvgl/lvgl: component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae dependencies: [] @@ -155,8 +185,9 @@ direct_dependencies: - espressif/esp_lcd_touch_st7123 - espressif/esp_lvgl_port - espressif/esp_serial_slave_link +- espressif/esp_tinyusb - espressif/usb_host_hid - idf -manifest_hash: 494230f4f62e4af99b62d8b0a3baaab488edc4bbdf31ded1de2e17c3e49d2c85 +manifest_hash: 0f53e41a9df3bf85334919dbd5eb656fe86761f6aec28bc3cbd86311c5718cb0 target: esp32p4 version: 2.0.0 diff --git a/docs/reticulum/micron-support-corpus.md b/docs/reticulum/micron-support-corpus.md new file mode 100644 index 00000000..7cce2f95 --- /dev/null +++ b/docs/reticulum/micron-support-corpus.md @@ -0,0 +1,50 @@ +# Network Micron Support Corpus + +Trail Mate's Network page intentionally implements a bounded Micron renderer. The +goal is to keep small Nomad pages readable on constrained devices, not to become a +full NomadNet client with form submission, resource rendering, or recursive +partial execution. + +Current corpus sample: + +| Sample | Path | Size | Purpose | +| --- | --- | ---: | --- | +| Trail Mate showcase | `docs/reticulum/pages/trail-mate/index.mu` | 3366 bytes | Real page used to describe Trail Mate and exercise the supported renderer subset | + +Compatibility table for the current sample: + +| Micron feature | Example in corpus | Current Network behavior | Runtime cost policy | +| --- | --- | --- | --- | +| Header colors | `#!fg=ddd`, `#!bg=050505` | Applies page foreground/background defaults | Parsed once per page | +| Comments | `# Trail Mate Micron showcase page...` | Skipped | No objects | +| Headings | `>Trail Mate`, `>>Overview` | Rendered with heading styling and auto anchors | One row per heading | +| Explicit anchors | `` `:overview `` | Registered for same-page jumps | Bounded by anchor pool | +| Non-ASCII text | Chinese sentence in the engine intro | Rendered as UTF-8 text; slug helper preserves UTF-8 heading text | No transcoding buffer | +| Inline emphasis | `` `!Bold`! ``, `` `_Underline`_ `` | Bold/underline state is reflected in labels | Chunked text labels | +| Inline colors | `` `F6cf...`f ``, `` `B123...`b `` | Supports 3-digit, 6-digit true color, grayscale, and split color tokens | Shared contract parser | +| Alignment | `` `c ``, `` `l ``, `` `r ``, `` `a `` | Changes row flex alignment until reset | No persistent allocation | +| Escapes/reset | `\`not-a-tag`, double tick reset | Escaped text remains literal; reset clears style | Inline scan only | +| Dividers | `-*`, `-=` | Rendered as repeated divider text | One row | +| Literal block | `` `= `` block in engine section | Rendered as preformatted display text with subtle block styling | One row per literal line | +| Page links | `` `[`Same page at limits`/page/index.mu`anchor=limits] `` | Clickable when target is page or anchor | Bounded link pool | +| Anchor links | `` `[`Overview`#overview] `` | Clickable same-page jump | Bounded link pool | +| Link anchor hint | `anchor=matrix` | Appends `#matrix` before navigation | Contract-tested | +| Submit fields | `callsign|offline|transport|*` | Link remains visible with `[no submit]`; page notice says submit unsupported | No form state | +| Text fields | `` `<18|callsign`TRAIL-MATE> `` | Display-only read-only field | One LVGL textarea object | +| Masked fields | `` ` `` | Display-only password-style field | One LVGL textarea object | +| Checkbox/radio fields | `` ```, `` `<^...>`` | Display-only disabled controls | One LVGL checkbox object | +| Tables | `` `tc42 ``, `` `tr38 ``, `` `tl36 `` | Bounded table rows with header styling and column alignment | Max 6 cells per row | +| Table separators | `|:--|:--:|--:|` | Updates column alignment; separator row is not rendered | Contract-tested | +| Page partials | `` `{:/page/live-status.mu`30} `` | Shows unsupported page partial and an `Open partial` page link | No inline fetch | +| Resource links | `` `[`Field guide PDF`:/file/...pdf] `` | Shows label plus `[resource unsupported]`; not clickable | No resource fetch | +| Unknown tags | `` `q `` | Counts and samples unsupported tags in a notice | Small stack-only sample set | +| Render limits | Link, anchor, row, object caps | Shows limit notices instead of growing unbounded | PSRAM-first pools where dynamic | + +Out of scope for this renderer: + +| Feature | Reason | +| --- | --- | +| Recursive partial execution | Would add nested network/cache failure states and unpredictable object growth | +| Form submission | Requires session state and write semantics the device browser does not own | +| Image/file/resource rendering | High memory and transfer cost; current behavior is explicit fallback | +| Full upstream layout parity | Flash is already tight, and exact desktop-like layout is not the product goal | diff --git a/docs/sound/call.mp3 b/docs/sound/call.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a7eedff1d4447002f8b0e157a9347c74598be0ae GIT binary patch literal 1633 zcmaKtX;4#F7>3VHfO|t&f(iyYESlpGdD>yCBTHL zk~M4wsKC6a5Wb{nrCL@90mR}B9j4ifd5o76kjH|P!PF7`=sy>m;JB;Ol2#Smg%Bkm z?x4!z<(mZ#jka5pIo1V_3JUukpZSv1y=Opk=~)>LA;%8s5`&v$kd?KE%Ydj^0=mA{ zL0vb0*TSkme%9d`XPK|OvQ96Y;lFG%oN^a__@S}3$Dx_XAsGuSkz$Zhl@9q#b7#IobLbJ{tM5WIBxo^&Z38=DfMfeM$0hnfufPiinkC>n(Gm&}XJx}b;*o$1t4 zD+?us9MSHau`EQcT{~QHv%n#?hr4hK1 z44Q!^+O-3!@NLG#>xd=-{po5n8(_48d}=;EzTau}1lWX|!uch0@Ie*Than~zcT5bK zGO)iI3}ggsJY}q;E1O-=qm_By@c}r5hE#L-Jg2VSa)5ds=YsF2>WNP5)+Hk{+%W0H1LcO@{BMb0Sk~RGY0jjOAC`_{G9<1?ZAzhb!Z> z-q8u=7cE}NLqXpGy!z&NJSC^g zpk6vc$?4way0R_Px*GL2WRgOW*dRsDqjV|V{W>)L7{W?*+pW6aU%P6VT>7<%zKA+yH^GnzG zWf}#`UsM5vWD+pGYzqVFRkjTtHFQk8xcve!<))?_bgp8g%JL*@H1UKj{Uhjf5)UJ{ z?aKF(LhCTTwS<99W3;zgacr$0`u_3w>4&{5)9V$2cWur&l8@^had`fOyS^{1^%A~T zG)e}g(3~j}2euF}Z?q{OoWQ~a^&au>D|B@!b(jzH?69|uuh$SG@l@IE=+CT;o+OiM zk6f)qh+_a6Q-`xoLB(%&q01EN8={eG!2@!aS@&FSy%?%dZ3$24eULVq7D+O(bPw6n zT`dwMAfFQ_rV3(?@Qi-5NClOS7cCcQ>pFE0pr3v xZ%!$3(=w&jn|Qd`D|channel != TM_C6_CH_WIFI_DATA || + frame->payload_len < sizeof(tm_c6_wifi_tcp_header_t)) + { + return send_error_frame(frame, TM_C6_ERROR_UNSUPPORTED_FRAME, "bad_wifi_data"); + } + const esp_err_t err = tm_wifi_tcp_handle_frame(frame->payload, frame->payload_len); + if (err == ESP_ERR_NO_MEM) + { + return send_error_frame(frame, TM_C6_ERROR_QUEUE_FULL, "wifi_tcp_queue_full"); + } + if (err != ESP_OK) + { + return send_error_frame(frame, TM_C6_ERROR_UNSUPPORTED_FRAME, "wifi_tcp_frame_invalid"); + } + return true; +} + static bool handle_diag_request(const tm_c6_frame_view_t* frame) { tm_c6_diag_report_t report = {}; @@ -473,6 +494,9 @@ static void hostlink_task(void* arg) case TM_C6_FRAME_WIFI_CONTROL: (void)handle_wifi_control(&decoded.frame); break; + case TM_C6_FRAME_WIFI_DATA: + (void)handle_wifi_data(&decoded.frame); + break; case TM_C6_FRAME_DIAG_REQUEST: (void)handle_diag_request(&decoded.frame); break; diff --git a/firmware/c6_companion/components/tm_hostlink/tm_hostlink_sdio.c b/firmware/c6_companion/components/tm_hostlink/tm_hostlink_sdio.c index d0ea2cea..5e42fd4c 100644 --- a/firmware/c6_companion/components/tm_hostlink/tm_hostlink_sdio.c +++ b/firmware/c6_companion/components/tm_hostlink/tm_hostlink_sdio.c @@ -4,6 +4,7 @@ #include "esp_heap_caps.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include @@ -22,6 +23,7 @@ static sdio_slave_buf_handle_t s_rx_handles[TM_C6_SDIO_RX_BUFFER_NUM]; static uint8_t* s_tx_buffers[TM_C6_SDIO_TX_BUFFER_NUM]; static bool s_tx_busy[TM_C6_SDIO_TX_BUFFER_NUM]; static bool s_initialized = false; +static SemaphoreHandle_t s_tx_mutex; static TickType_t ticks_from_ms(uint32_t timeout_ms) { @@ -44,6 +46,15 @@ esp_err_t tm_hostlink_sdio_init(void) return ESP_OK; } + if (s_tx_mutex == NULL) + { + s_tx_mutex = xSemaphoreCreateMutex(); + if (s_tx_mutex == NULL) + { + return ESP_ERR_NO_MEM; + } + } + sdio_slave_config_t config = { .sending_mode = SDIO_SLAVE_SEND_PACKET, .send_queue_size = TM_C6_SDIO_QUEUE_SIZE, @@ -111,7 +122,7 @@ esp_err_t tm_hostlink_sdio_init(void) return ESP_OK; } -void tm_hostlink_sdio_poll_tx_done(void) +static void poll_tx_done_locked(void) { void* arg = NULL; while (sdio_slave_send_get_finished(&arg, 0) == ESP_OK) @@ -125,6 +136,16 @@ void tm_hostlink_sdio_poll_tx_done(void) } } +void tm_hostlink_sdio_poll_tx_done(void) +{ + if (s_tx_mutex == NULL || xSemaphoreTake(s_tx_mutex, portMAX_DELAY) != pdTRUE) + { + return; + } + poll_tx_done_locked(); + xSemaphoreGive(s_tx_mutex); +} + esp_err_t tm_hostlink_sdio_recv(uint8_t* data, size_t max_len, size_t* out_len, uint32_t timeout_ms) { if (!s_initialized || data == NULL || out_len == NULL || max_len == 0) @@ -171,7 +192,12 @@ esp_err_t tm_hostlink_sdio_send(const uint8_t* data, size_t len, uint32_t timeou return ESP_ERR_INVALID_ARG; } - tm_hostlink_sdio_poll_tx_done(); + if (s_tx_mutex == NULL || xSemaphoreTake(s_tx_mutex, ticks_from_ms(timeout_ms)) != pdTRUE) + { + return ESP_ERR_TIMEOUT; + } + + poll_tx_done_locked(); size_t tx_index = TM_C6_SDIO_TX_BUFFER_NUM; for (size_t i = 0; i < TM_C6_SDIO_TX_BUFFER_NUM; ++i) @@ -185,6 +211,7 @@ esp_err_t tm_hostlink_sdio_send(const uint8_t* data, size_t len, uint32_t timeou if (tx_index == TM_C6_SDIO_TX_BUFFER_NUM) { ESP_LOGW(TAG, "send no free tx buffer len=%u timeout_ms=%lu", (unsigned)len, (unsigned long)timeout_ms); + xSemaphoreGive(s_tx_mutex); return ESP_ERR_NOT_FOUND; } @@ -205,6 +232,7 @@ esp_err_t tm_hostlink_sdio_send(const uint8_t* data, size_t len, uint32_t timeou (unsigned)tx_index, (unsigned)len, esp_err_to_name(err)); + xSemaphoreGive(s_tx_mutex); return err; } ESP_LOGI(TAG, "send queue ok index=%u len=%u", (unsigned)tx_index, (unsigned)len); @@ -217,5 +245,6 @@ esp_err_t tm_hostlink_sdio_send(const uint8_t* data, size_t len, uint32_t timeou { ESP_LOGI(TAG, "send host int ok index=%u", (unsigned)tx_index); } + xSemaphoreGive(s_tx_mutex); return ESP_OK; } diff --git a/firmware/c6_companion/components/tm_services/include/tm_services.h b/firmware/c6_companion/components/tm_services/include/tm_services.h index 4ef852df..c8935ea9 100644 --- a/firmware/c6_companion/components/tm_services/include/tm_services.h +++ b/firmware/c6_companion/components/tm_services/include/tm_services.h @@ -55,6 +55,7 @@ extern "C" bool tm_services_send_espnow_uplink(const tm_c6_espnow_packet_t* packet); bool tm_services_send_espnow_event(const tm_c6_espnow_event_t* event); bool tm_services_send_wifi_event(const tm_c6_wifi_event_t* event); + bool tm_services_send_wifi_data(const uint8_t* payload, size_t payload_len); bool tm_services_send_wifi_time_sync(const tm_c6_wifi_time_sync_t* event); bool tm_services_send_log(const char* message); void tm_services_flush_logs(void); diff --git a/firmware/c6_companion/components/tm_services/tm_services.c b/firmware/c6_companion/components/tm_services/tm_services.c index a1eadede..f5362441 100644 --- a/firmware/c6_companion/components/tm_services/tm_services.c +++ b/firmware/c6_companion/components/tm_services/tm_services.c @@ -81,7 +81,7 @@ static uint32_t configured_wifi_feature_mask(void) { if (s_config.wifi.sta_enabled) { - features |= TM_C6_FEATURE_WIFI_STA; + features |= TM_C6_FEATURE_WIFI_STA | TM_C6_FEATURE_WIFI_TCP_PROXY; } if (s_config.wifi.ap_enabled) { @@ -126,7 +126,8 @@ uint32_t tm_services_supported_features(void) return TM_C6_FEATURE_BLE_MESHTASTIC | TM_C6_FEATURE_BLE_MESHCORE | TM_C6_FEATURE_BLE_TRAILMATE | TM_C6_FEATURE_ESPNOW_TEAM | TM_C6_FEATURE_WIFI_STA | TM_C6_FEATURE_WIFI_AP | - TM_C6_FEATURE_DIAG_LOG | TM_C6_FEATURE_HOSTLINK_PING; + TM_C6_FEATURE_DIAG_LOG | TM_C6_FEATURE_HOSTLINK_PING | + TM_C6_FEATURE_WIFI_TCP_PROXY; } uint32_t tm_services_enabled_features(void) @@ -400,6 +401,17 @@ bool tm_services_send_wifi_event(const tm_c6_wifi_event_t* event) return send_frame(TM_C6_FRAME_WIFI_EVENT, TM_C6_CH_WIFI_MGMT, (const uint8_t*)event, sizeof(*event)); } +bool tm_services_send_wifi_data(const uint8_t* payload, size_t payload_len) +{ + if (payload == NULL || payload_len < sizeof(tm_c6_wifi_tcp_header_t) || + payload_len > TM_C6_MAX_PAYLOAD) + { + tm_services_record_error(TM_C6_ERROR_PAYLOAD_TOO_LARGE, "wifi_tcp_payload_invalid"); + return false; + } + return send_frame(TM_C6_FRAME_WIFI_DATA, TM_C6_CH_WIFI_DATA, payload, payload_len); +} + bool tm_services_send_wifi_time_sync(const tm_c6_wifi_time_sync_t* event) { if (event == NULL) diff --git a/firmware/c6_companion/components/tm_wifi/CMakeLists.txt b/firmware/c6_companion/components/tm_wifi/CMakeLists.txt index 54e98ccc..6d763db0 100644 --- a/firmware/c6_companion/components/tm_wifi/CMakeLists.txt +++ b/firmware/c6_companion/components/tm_wifi/CMakeLists.txt @@ -1,5 +1,7 @@ idf_component_register( - SRCS "tm_wifi.c" + SRCS + "tm_wifi.c" + "tm_wifi_tcp.c" INCLUDE_DIRS "include" "../../../../modules/core_hostlink/include" diff --git a/firmware/c6_companion/components/tm_wifi/include/tm_wifi_tcp.h b/firmware/c6_companion/components/tm_wifi/include/tm_wifi_tcp.h new file mode 100644 index 00000000..9570ac98 --- /dev/null +++ b/firmware/c6_companion/components/tm_wifi/include/tm_wifi_tcp.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esp_err.h" + +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + esp_err_t tm_wifi_tcp_init(void); + esp_err_t tm_wifi_tcp_handle_frame(const uint8_t* payload, size_t payload_len); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/c6_companion/components/tm_wifi/tm_wifi_tcp.c b/firmware/c6_companion/components/tm_wifi/tm_wifi_tcp.c new file mode 100644 index 00000000..8ed5bb18 --- /dev/null +++ b/firmware/c6_companion/components/tm_wifi/tm_wifi_tcp.c @@ -0,0 +1,388 @@ +#include "tm_wifi_tcp.h" + +#include "hostlink/c6/c6_protocol.h" +#include "tm_services.h" + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "lwip/errno.h" +#include "lwip/netdb.h" +#include "lwip/sockets.h" + +#include +#include +#include +#include +#include + +static const char* TAG = "C6_WIFI_TCP"; + +enum +{ + TM_WIFI_TCP_QUEUE_DEPTH = 4, + TM_WIFI_TCP_TASK_STACK_BYTES = 6144, + TM_WIFI_TCP_TASK_PRIORITY = 7, + TM_WIFI_TCP_POLL_MS = 10, + TM_WIFI_TCP_CONNECT_TIMEOUT_SECONDS = 6, +}; + +typedef struct tm_wifi_tcp_command +{ + tm_c6_wifi_tcp_header_t header; + uint8_t payload[TM_C6_WIFI_TCP_PAYLOAD_MAX]; +} tm_wifi_tcp_command_t; + +static QueueHandle_t s_command_queue; +static TaskHandle_t s_worker_task; +static int s_socket = -1; +static uint8_t s_connection_id; +static uint8_t s_event_payload[TM_C6_MAX_PAYLOAD]; +static uint8_t s_rx_payload[TM_C6_WIFI_TCP_PAYLOAD_MAX]; +static tm_wifi_tcp_command_t s_worker_command; +static tm_wifi_tcp_command_t s_enqueue_command; +static size_t s_pending_event_len; + +static bool flush_pending_event(void) +{ + if (s_pending_event_len == 0) + { + return true; + } + if (!tm_services_send_wifi_data(s_event_payload, s_pending_event_len)) + { + return false; + } + s_pending_event_len = 0; + return true; +} + +static void close_socket(void) +{ + if (s_socket >= 0) + { + shutdown(s_socket, SHUT_RDWR); + close(s_socket); + s_socket = -1; + } +} + +static bool emit_event(uint8_t operation, + uint8_t connection_id, + uint16_t error_code, + const uint8_t* data, + size_t data_len) +{ + if (data_len > TM_C6_WIFI_TCP_PAYLOAD_MAX || (data == NULL && data_len != 0)) + { + return false; + } + if (s_pending_event_len != 0) + { + return false; + } + + tm_c6_wifi_tcp_header_t header = { + .operation = operation, + .connection_id = connection_id, + .payload_len = (uint16_t)data_len, + .port = 0, + .error_code = error_code, + }; + memcpy(s_event_payload, &header, sizeof(header)); + if (data_len > 0) + { + memcpy(s_event_payload + sizeof(header), data, data_len); + } + s_pending_event_len = sizeof(header) + data_len; + (void)flush_pending_event(); + return true; +} + +static void fail_connection(uint16_t error_code, const char* detail) +{ + const uint8_t connection_id = s_connection_id; + close_socket(); + tm_services_record_error(error_code, detail); + (void)emit_event(TM_C6_WIFI_TCP_ERROR, connection_id, error_code, NULL, 0); +} + +static bool connect_with_timeout(int socket_fd, + const struct sockaddr* address, + socklen_t address_len) +{ + const int original_flags = fcntl(socket_fd, F_GETFL, 0); + if (original_flags < 0 || + fcntl(socket_fd, F_SETFL, original_flags | O_NONBLOCK) != 0) + { + return false; + } + + bool connected = connect(socket_fd, address, address_len) == 0; + if (!connected && errno == EINPROGRESS) + { + fd_set write_set; + FD_ZERO(&write_set); + FD_SET(socket_fd, &write_set); + struct timeval timeout = { + .tv_sec = TM_WIFI_TCP_CONNECT_TIMEOUT_SECONDS, + .tv_usec = 0, + }; + const int selected = select(socket_fd + 1, NULL, &write_set, NULL, &timeout); + if (selected > 0 && FD_ISSET(socket_fd, &write_set)) + { + int socket_error = 0; + socklen_t error_len = sizeof(socket_error); + connected = getsockopt(socket_fd, + SOL_SOCKET, + SO_ERROR, + &socket_error, + &error_len) == 0 && + socket_error == 0; + } + } + + if (fcntl(socket_fd, F_SETFL, original_flags) != 0) + { + connected = false; + } + return connected; +} + +static esp_err_t open_socket(const tm_wifi_tcp_command_t* command) +{ + if (command->header.host[0] == '\0' || command->header.port == 0) + { + return ESP_ERR_INVALID_ARG; + } + + close_socket(); + s_connection_id = command->header.connection_id; + + char port_text[6] = {}; + snprintf(port_text, sizeof(port_text), "%u", (unsigned)command->header.port); + struct addrinfo hints = { + .ai_family = AF_UNSPEC, + .ai_socktype = SOCK_STREAM, + }; + struct addrinfo* result = NULL; + const int lookup = getaddrinfo(command->header.host, port_text, &hints, &result); + if (lookup != 0 || result == NULL) + { + ESP_LOGW(TAG, "resolve failed host=%s code=%d", command->header.host, lookup); + if (result != NULL) + { + freeaddrinfo(result); + } + return ESP_ERR_NOT_FOUND; + } + + int connected_socket = -1; + for (const struct addrinfo* address = result; address != NULL; address = address->ai_next) + { + const int candidate = socket(address->ai_family, address->ai_socktype, address->ai_protocol); + if (candidate < 0) + { + continue; + } + + if (connect_with_timeout(candidate, address->ai_addr, address->ai_addrlen)) + { + connected_socket = candidate; + break; + } + close(candidate); + } + freeaddrinfo(result); + + if (connected_socket < 0) + { + ESP_LOGW(TAG, + "connect failed host=%s port=%u errno=%d", + command->header.host, + (unsigned)command->header.port, + errno); + return ESP_ERR_INVALID_STATE; + } + + s_socket = connected_socket; + ESP_LOGI(TAG, + "connected id=%u host=%s port=%u", + (unsigned)s_connection_id, + command->header.host, + (unsigned)command->header.port); + (void)emit_event(TM_C6_WIFI_TCP_OPENED, s_connection_id, TM_C6_OK, NULL, 0); + return ESP_OK; +} + +static esp_err_t write_socket(const tm_wifi_tcp_command_t* command) +{ + if (s_socket < 0 || command->header.connection_id != s_connection_id) + { + return ESP_ERR_INVALID_STATE; + } + + size_t sent = 0; + while (sent < command->header.payload_len) + { + const int result = send(s_socket, + command->payload + sent, + command->header.payload_len - sent, + 0); + if (result <= 0) + { + return ESP_ERR_INVALID_STATE; + } + sent += (size_t)result; + } + return ESP_OK; +} + +static void process_command(const tm_wifi_tcp_command_t* command) +{ + switch (command->header.operation) + { + case TM_C6_WIFI_TCP_OPEN: + if (open_socket(command) != ESP_OK) + { + fail_connection(TM_C6_ERROR_NOT_CONNECTED, "wifi_tcp_open_failed"); + } + break; + case TM_C6_WIFI_TCP_WRITE: + if (write_socket(command) != ESP_OK) + { + fail_connection(TM_C6_ERROR_NOT_CONNECTED, "wifi_tcp_write_failed"); + } + break; + case TM_C6_WIFI_TCP_CLOSE: + { + const uint8_t connection_id = command->header.connection_id; + close_socket(); + (void)emit_event(TM_C6_WIFI_TCP_CLOSED, connection_id, TM_C6_OK, NULL, 0); + break; + } + default: + tm_services_record_error(TM_C6_ERROR_UNSUPPORTED_FRAME, "wifi_tcp_operation_invalid"); + break; + } +} + +static void poll_socket(void) +{ + if (s_socket < 0) + { + return; + } + + const int received = recv(s_socket, s_rx_payload, sizeof(s_rx_payload), MSG_DONTWAIT); + if (received > 0) + { + (void)emit_event(TM_C6_WIFI_TCP_DATA, + s_connection_id, + TM_C6_OK, + s_rx_payload, + (size_t)received); + return; + } + if (received == 0) + { + const uint8_t connection_id = s_connection_id; + close_socket(); + (void)emit_event(TM_C6_WIFI_TCP_CLOSED, connection_id, TM_C6_OK, NULL, 0); + return; + } + if (errno != EAGAIN && errno != EWOULDBLOCK) + { + fail_connection(TM_C6_ERROR_NOT_CONNECTED, "wifi_tcp_receive_failed"); + } +} + +static void wifi_tcp_task(void* arg) +{ + (void)arg; + for (;;) + { + if (!flush_pending_event()) + { + vTaskDelay(pdMS_TO_TICKS(TM_WIFI_TCP_POLL_MS)); + continue; + } + if (xQueueReceive(s_command_queue, + &s_worker_command, + pdMS_TO_TICKS(TM_WIFI_TCP_POLL_MS)) == pdTRUE) + { + process_command(&s_worker_command); + } + poll_socket(); + } +} + +esp_err_t tm_wifi_tcp_init(void) +{ + if (s_worker_task != NULL) + { + return ESP_OK; + } + + s_pending_event_len = 0; + + s_command_queue = xQueueCreate(TM_WIFI_TCP_QUEUE_DEPTH, sizeof(tm_wifi_tcp_command_t)); + if (s_command_queue == NULL) + { + return ESP_ERR_NO_MEM; + } + if (xTaskCreate(wifi_tcp_task, + "tm_wifi_tcp", + TM_WIFI_TCP_TASK_STACK_BYTES, + NULL, + TM_WIFI_TCP_TASK_PRIORITY, + &s_worker_task) != pdPASS) + { + vQueueDelete(s_command_queue); + s_command_queue = NULL; + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +esp_err_t tm_wifi_tcp_handle_frame(const uint8_t* payload, size_t payload_len) +{ + if (payload == NULL || payload_len < sizeof(tm_c6_wifi_tcp_header_t)) + { + return ESP_ERR_INVALID_ARG; + } + + memset(&s_enqueue_command, 0, sizeof(s_enqueue_command)); + memcpy(&s_enqueue_command.header, payload, sizeof(s_enqueue_command.header)); + const size_t data_len = payload_len - sizeof(s_enqueue_command.header); + if (s_enqueue_command.header.payload_len != data_len || + data_len > sizeof(s_enqueue_command.payload) || + (s_enqueue_command.header.operation != TM_C6_WIFI_TCP_OPEN && + s_enqueue_command.header.operation != TM_C6_WIFI_TCP_WRITE && + s_enqueue_command.header.operation != TM_C6_WIFI_TCP_CLOSE)) + { + return ESP_ERR_INVALID_ARG; + } + if ((s_enqueue_command.header.operation == TM_C6_WIFI_TCP_OPEN && + (data_len != 0 || s_enqueue_command.header.host[0] == '\0' || + s_enqueue_command.header.port == 0)) || + (s_enqueue_command.header.operation == TM_C6_WIFI_TCP_WRITE && data_len == 0) || + (s_enqueue_command.header.operation == TM_C6_WIFI_TCP_CLOSE && data_len != 0)) + { + return ESP_ERR_INVALID_ARG; + } + s_enqueue_command.header.host[sizeof(s_enqueue_command.header.host) - 1] = '\0'; + if (data_len > 0) + { + memcpy(s_enqueue_command.payload, + payload + sizeof(s_enqueue_command.header), + data_len); + } + if (s_command_queue == NULL || + xQueueSend(s_command_queue, &s_enqueue_command, 0) != pdTRUE) + { + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} diff --git a/firmware/c6_companion/main/app_main.c b/firmware/c6_companion/main/app_main.c index 6b6902f3..c9ed0296 100644 --- a/firmware/c6_companion/main/app_main.c +++ b/firmware/c6_companion/main/app_main.c @@ -5,6 +5,7 @@ #include "tm_hostlink.h" #include "tm_services.h" #include "tm_wifi.h" +#include "tm_wifi_tcp.h" #include "esp_event.h" #include "esp_log.h" @@ -37,6 +38,7 @@ void app_main(void) tm_diag_init(); ESP_ERROR_CHECK(tm_services_init()); ESP_ERROR_CHECK(tm_wifi_init()); + ESP_ERROR_CHECK(tm_wifi_tcp_init()); ESP_ERROR_CHECK(tm_espnow_init()); ESP_ERROR_CHECK(tm_ble_init()); tm_hostlink_init(); diff --git a/modules/core_chat/include/chat/domain/chat_types.h b/modules/core_chat/include/chat/domain/chat_types.h index a891f712..62ebe597 100644 --- a/modules/core_chat/include/chat/domain/chat_types.h +++ b/modules/core_chat/include/chat/domain/chat_types.h @@ -387,6 +387,7 @@ struct ChatMessage int32_t geo_lat_e7; int32_t geo_lon_e7; ReticulumPeerIdentity reticulum_identity{}; + bool source_unverified; RxOrigin rx_origin; MessageStatus status; @@ -397,6 +398,7 @@ struct ChatMessage has_geo(false), geo_lat_e7(0), geo_lon_e7(0), + source_unverified(false), rx_origin(RxOrigin::Unknown), status(MessageStatus::Incoming) {} }; @@ -441,6 +443,7 @@ struct MeshIncomingText uint8_t hop_limit; // Remaining hops bool encrypted; // Whether message was encrypted ReticulumPeerIdentity reticulum_identity{}; + bool source_unverified = false; RxMeta rx_meta; }; @@ -503,6 +506,12 @@ enum class ReticulumInterfacePolicy : uint8_t WifiGatewayOnly = 2, }; +enum class ReticulumCallWireProfile : uint8_t +{ + SidebandLxst = 0, + MeshChatCallAudio = 1, +}; + struct MeshConfig { // Meshtastic radio configuration @@ -567,6 +576,8 @@ struct MeshConfig char reticulum_wifi_gateway_host[kReticulumGatewayHostMaxLen + 1]; uint16_t reticulum_wifi_gateway_port; ReticulumInterfacePolicy reticulum_interface_policy; + ReticulumCallWireProfile reticulum_call_wire_profile; + bool reticulum_allow_location_requests; ReticulumGroupDestinationConfig reticulum_groups[kReticulumGroupDestinationMaxCount]; MeshConfig() @@ -612,7 +623,9 @@ struct MeshConfig reticulum_wifi_auto_connect(true), reticulum_anonymous_peer(false), reticulum_wifi_gateway_port(4242), - reticulum_interface_policy(ReticulumInterfacePolicy::All) + reticulum_interface_policy(ReticulumInterfacePolicy::All), + reticulum_call_wire_profile(ReticulumCallWireProfile::SidebandLxst), + reticulum_allow_location_requests(false) { strncpy(primary_channel_name, "LongFast", sizeof(primary_channel_name) - 1); primary_channel_name[sizeof(primary_channel_name) - 1] = '\0'; diff --git a/modules/core_chat/include/chat/domain/mesh_peer_directory.h b/modules/core_chat/include/chat/domain/mesh_peer_directory.h index 3d2e30c2..cff18f05 100644 --- a/modules/core_chat/include/chat/domain/mesh_peer_directory.h +++ b/modules/core_chat/include/chat/domain/mesh_peer_directory.h @@ -16,6 +16,7 @@ constexpr std::size_t kMeshPeerPublicKeyMaxLen = 64; constexpr std::size_t kMeshPeerMeshtasticPublicKeyLen = 32; constexpr std::size_t kMeshPeerMeshCorePublicKeyLen = 32; constexpr std::size_t kMeshPeerReticulumPublicKeyLen = 32; +constexpr std::size_t kMeshPeerReticulumRatchetLen = 32; constexpr std::size_t kMeshPeerMacAddrLen = 6; enum class MeshPeerSource : uint8_t @@ -91,6 +92,9 @@ struct ReticulumPeerFacts bool has_public_keys = false; uint8_t enc_pub[kMeshPeerReticulumPublicKeyLen] = {}; uint8_t sig_pub[kMeshPeerReticulumPublicKeyLen] = {}; + bool has_ratchet = false; + uint8_t ratchet_pub[kMeshPeerReticulumRatchetLen] = {}; + uint32_t ratchet_seen_s = 0; bool delivery = false; bool propagation = false; }; diff --git a/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h b/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h index 661caa52..bf4a6e67 100644 --- a/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h +++ b/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h @@ -15,6 +15,16 @@ namespace chat::lxmf { +constexpr uint32_t kFieldTelemetry = 0x02; +constexpr uint32_t kFieldTelemetryStream = 0x03; +constexpr uint32_t kFieldCommands = 0x09; + +struct DecodedField +{ + uint32_t key = 0; + std::vector encoded_value; +}; + struct DecodedEnvelope { uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; @@ -31,6 +41,7 @@ struct DecodedTextPayload bool has_stamp = false; std::vector stamp; bool fields_empty = true; + std::vector fields; }; struct DecodedMessage : public DecodedEnvelope @@ -41,6 +52,24 @@ struct DecodedMessage : public DecodedEnvelope bool has_stamp = false; std::vector stamp; bool fields_empty = true; + std::vector fields; +}; + +struct SidebandTelemetryLocation +{ + bool valid = false; + int32_t latitude_e6 = 0; + int32_t longitude_e6 = 0; + int32_t altitude_cm = 0; + uint32_t accuracy_cm = 0; + uint32_t timestamp = 0; +}; + +struct SidebandTelemetryRequest +{ + bool valid = false; + uint32_t timebase = 0; + bool collector_request = false; }; struct DecodedAppData @@ -240,6 +269,16 @@ bool packMessage(const uint8_t destination_hash[reticulum::kTruncatedHashSize], bool unpackMessageEnvelope(const uint8_t* data, size_t len, DecodedEnvelope* out_envelope); bool unpackTextPayload(const uint8_t* data, size_t len, DecodedTextPayload* out_payload); +bool encodeSidebandTelemetryLocationPayload( + double message_timestamp, + const SidebandTelemetryLocation& location, + uint8_t* out_payload, + size_t* inout_len); +const DecodedField* findField(const DecodedTextPayload& payload, uint32_t key); +bool decodeSidebandTelemetryLocation(const DecodedTextPayload& payload, + SidebandTelemetryLocation* out_location); +bool decodeSidebandTelemetryRequest(const DecodedTextPayload& payload, + SidebandTelemetryRequest* out_request); bool decodeAppDataPayload(const uint8_t* data, size_t len, DecodedAppData* out_payload); bool unpackMessage(const uint8_t* data, size_t len, DecodedMessage* out_message); diff --git a/modules/core_chat/include/chat/infra/mesh_incoming_queue.h b/modules/core_chat/include/chat/infra/mesh_incoming_queue.h index 69d424e7..aea9783f 100644 --- a/modules/core_chat/include/chat/infra/mesh_incoming_queue.h +++ b/modules/core_chat/include/chat/infra/mesh_incoming_queue.h @@ -113,6 +113,7 @@ class IncomingTextQueue slot.hop_limit = metadata.hop_limit; slot.encrypted = metadata.encrypted; slot.reticulum_identity = metadata.reticulum_identity; + slot.source_unverified = metadata.source_unverified; slot.rx_meta = metadata.rx_meta; slot.text_len = text_len; if (text_len > 0) @@ -140,6 +141,7 @@ class IncomingTextQueue out->hop_limit = slot.hop_limit; out->encrypted = slot.encrypted; out->reticulum_identity = slot.reticulum_identity; + out->source_unverified = slot.source_unverified; out->rx_meta = slot.rx_meta; out->text.assign(slot.text.data(), slot.text_len); @@ -163,6 +165,7 @@ class IncomingTextQueue uint8_t hop_limit = 0xFF; bool encrypted = false; ReticulumPeerIdentity reticulum_identity{}; + bool source_unverified = false; RxMeta rx_meta{}; std::array text{}; std::size_t text_len = 0; diff --git a/modules/core_chat/include/chat/infra/reticulum/lxst_telephony_wire.h b/modules/core_chat/include/chat/infra/reticulum/lxst_telephony_wire.h new file mode 100644 index 00000000..d3ccaecd --- /dev/null +++ b/modules/core_chat/include/chat/infra/reticulum/lxst_telephony_wire.h @@ -0,0 +1,75 @@ +/** + * @file lxst_telephony_wire.h + * @brief LXST telephony signalling and Codec2 frame MsgPack subset. + */ + +#pragma once + +#include "chat/infra/reticulum/audio_call_wire.h" + +#include +#include + +namespace chat::reticulum::lxst +{ + +constexpr uint16_t kStatusBusy = 0x00; +constexpr uint16_t kStatusRejected = 0x01; +constexpr uint16_t kStatusCalling = 0x02; +constexpr uint16_t kStatusAvailable = 0x03; +constexpr uint16_t kStatusRinging = 0x04; +constexpr uint16_t kStatusConnecting = 0x05; +constexpr uint16_t kStatusEstablished = 0x06; +constexpr uint16_t kPreferredProfile = 0xFF; + +constexpr uint16_t kProfileBandwidthUltraLow = 0x10; +constexpr uint16_t kProfileBandwidthVeryLow = 0x20; +constexpr uint16_t kProfileBandwidthLow = 0x30; +constexpr uint16_t kProfileQualityMedium = 0x40; +constexpr uint16_t kProfileQualityHigh = 0x50; +constexpr uint16_t kProfileQualityMax = 0x60; +constexpr uint16_t kProfileLatencyUltraLow = 0x70; +constexpr uint16_t kProfileLatencyLow = 0x80; + +constexpr uint8_t kCodecRaw = 0x00; +constexpr uint8_t kCodecOpus = 0x01; +constexpr uint8_t kCodec2 = 0x02; + +struct DecodedAudioFrame +{ + uint8_t codec = 0xFF; + audio_call::Codec2Mode codec2_mode = audio_call::Codec2Mode::Mode1200; + bool codec2_mode_valid = false; + const uint8_t* encoded = nullptr; + std::size_t encoded_len = 0; +}; + +struct DecodedPacket +{ + static constexpr std::size_t kMaxSignals = 4; + static constexpr std::size_t kMaxFrames = 4; + + uint16_t signals[kMaxSignals] = {}; + std::size_t signal_count = 0; + DecodedAudioFrame frames[kMaxFrames] = {}; + std::size_t frame_count = 0; +}; + +bool profileToCodec2Mode(uint16_t profile, + audio_call::Codec2Mode* out_mode); +bool codec2ModeToProfile(audio_call::Codec2Mode mode, + uint16_t* out_profile); + +bool encodeSignalling(uint16_t signal, + uint8_t* out, + std::size_t* inout_len); +bool encodeCodec2Frames(audio_call::Codec2Mode mode, + const uint8_t* encoded, + std::size_t encoded_len, + uint8_t* out, + std::size_t* inout_len); +bool decodePacket(const uint8_t* data, + std::size_t len, + DecodedPacket* out_packet); + +} // namespace chat::reticulum::lxst diff --git a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp index b93c190c..0c30a668 100644 --- a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp +++ b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp @@ -16,6 +16,8 @@ constexpr uint8_t kAppPayloadMagic[4] = {'T', 'M', 'A', 'P'}; constexpr uint8_t kAppPayloadVersion = 1; constexpr uint8_t kAppPayloadFlagWantResponse = 0x01; constexpr size_t kAppPayloadHeaderLen = 18; +constexpr uint32_t kSidebandSensorLocation = 0x02; +constexpr uint32_t kSidebandCommandTelemetryRequest = 0x01; struct Cursor { @@ -764,6 +766,105 @@ bool encodeTextPayload(double timestamp, return true; } +bool encodeSidebandTelemetryLocationPayload( + double message_timestamp, + const SidebandTelemetryLocation& location, + uint8_t* out_payload, + size_t* inout_len) +{ + if (!out_payload || !inout_len || + location.latitude_e6 < -90000000 || + location.latitude_e6 > 90000000 || + location.longitude_e6 < -180000000 || + location.longitude_e6 > 180000000 || + location.accuracy_cm > 0xFFFFU) + { + return false; + } + + uint8_t packed_telemetry[48] = {}; + size_t telemetry_used = 0; + uint8_t latitude[4] = {}; + uint8_t longitude[4] = {}; + uint8_t altitude[4] = {}; + const uint8_t zero_u32[4] = {}; + uint8_t accuracy[2] = {}; + writeU32Be(static_cast(location.latitude_e6), latitude); + writeU32Be(static_cast(location.longitude_e6), longitude); + writeU32Be(static_cast(location.altitude_cm), altitude); + accuracy[0] = static_cast((location.accuracy_cm >> 8U) & 0xFFU); + accuracy[1] = static_cast(location.accuracy_cm & 0xFFU); + + if (!appendMapHeader(1, + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendUint(kSidebandSensorLocation, + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendArrayHeader(7, + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendBin(latitude, + sizeof(latitude), + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendBin(longitude, + sizeof(longitude), + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendBin(altitude, + sizeof(altitude), + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendBin(zero_u32, + sizeof(zero_u32), + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendBin(zero_u32, + sizeof(zero_u32), + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendBin(accuracy, + sizeof(accuracy), + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used) || + !appendUint(location.timestamp, + packed_telemetry, + sizeof(packed_telemetry), + telemetry_used)) + { + return false; + } + + size_t used = 0; + if (!appendArrayHeader(4, out_payload, *inout_len, used) || + !appendFloat64(message_timestamp, out_payload, *inout_len, used) || + !appendBin(nullptr, 0, out_payload, *inout_len, used) || + !appendBin(nullptr, 0, out_payload, *inout_len, used) || + !appendMapHeader(1, out_payload, *inout_len, used) || + !appendUint(kFieldTelemetry, out_payload, *inout_len, used) || + !appendBin(packed_telemetry, + telemetry_used, + out_payload, + *inout_len, + used)) + { + return false; + } + + *inout_len = used; + return true; +} + bool encodeAppDataPayload(uint32_t portnum, uint32_t packet_id, uint32_t request_id, @@ -1672,10 +1773,33 @@ bool unpackTextPayload(const uint8_t* data, size_t len, DecodedTextPayload* out_ decoded.fields_empty = (map_count == 0); for (size_t i = 0; i < map_count; ++i) { - if (!skipObject(cursor) || !skipObject(cursor)) + Cursor key_cursor = cursor; + uint32_t field_key = 0; + const bool numeric_key = readUint(key_cursor, &field_key); + if (numeric_key) + { + cursor = key_cursor; + } + else if (!skipObject(cursor)) { return false; } + + const size_t value_start = cursor.pos; + if (!skipObject(cursor)) + { + return false; + } + if (numeric_key && + (field_key == kFieldTelemetry || + field_key == kFieldTelemetryStream || + field_key == kFieldCommands)) + { + DecodedField field{}; + field.key = field_key; + field.encoded_value.assign(data + value_start, data + cursor.pos); + decoded.fields.push_back(std::move(field)); + } } if (element_count == 5) @@ -1706,6 +1830,218 @@ bool unpackTextPayload(const uint8_t* data, size_t len, DecodedTextPayload* out_ return true; } +const DecodedField* findField(const DecodedTextPayload& payload, uint32_t key) +{ + const auto found = std::find_if(payload.fields.begin(), + payload.fields.end(), + [key](const DecodedField& field) + { return field.key == key; }); + return found != payload.fields.end() ? &*found : nullptr; +} + +bool decodeSidebandTelemetryLocation(const DecodedTextPayload& payload, + SidebandTelemetryLocation* out_location) +{ + if (!out_location) + { + return false; + } + *out_location = SidebandTelemetryLocation{}; + + const DecodedField* telemetry = findField(payload, kFieldTelemetry); + if (!telemetry || telemetry->encoded_value.empty()) + { + return false; + } + + Cursor field_cursor{}; + field_cursor.data = telemetry->encoded_value.data(); + field_cursor.len = telemetry->encoded_value.size(); + std::vector packed_telemetry; + if (!readBinary(field_cursor, &packed_telemetry) || packed_telemetry.empty()) + { + return false; + } + + Cursor telemetry_cursor{}; + telemetry_cursor.data = packed_telemetry.data(); + telemetry_cursor.len = packed_telemetry.size(); + size_t sensor_count = 0; + if (!readMapHeader(telemetry_cursor, &sensor_count)) + { + return false; + } + + for (size_t sensor_index = 0; sensor_index < sensor_count; ++sensor_index) + { + Cursor key_cursor = telemetry_cursor; + uint32_t sensor_id = 0; + const bool numeric_key = readUint(key_cursor, &sensor_id); + if (numeric_key) + { + telemetry_cursor = key_cursor; + } + else + { + if (!skipObject(telemetry_cursor) || !skipObject(telemetry_cursor)) + { + return false; + } + continue; + } + + if (sensor_id != kSidebandSensorLocation) + { + if (!skipObject(telemetry_cursor)) + { + return false; + } + continue; + } + + size_t location_elements = 0; + if (!readArrayHeader(telemetry_cursor, &location_elements) || + location_elements < 7) + { + return false; + } + + std::vector latitude; + std::vector longitude; + std::vector altitude; + std::vector speed; + std::vector bearing; + std::vector accuracy; + uint32_t timestamp = 0; + if (!readBinary(telemetry_cursor, &latitude) || latitude.size() != 4 || + !readBinary(telemetry_cursor, &longitude) || longitude.size() != 4 || + !readBinary(telemetry_cursor, &altitude) || altitude.size() != 4 || + !readBinary(telemetry_cursor, &speed) || speed.size() != 4 || + !readBinary(telemetry_cursor, &bearing) || bearing.size() != 4 || + !readBinary(telemetry_cursor, &accuracy) || accuracy.size() != 2 || + !readUint(telemetry_cursor, ×tamp)) + { + return false; + } + for (size_t index = 7; index < location_elements; ++index) + { + if (!skipObject(telemetry_cursor)) + { + return false; + } + } + + SidebandTelemetryLocation decoded{}; + decoded.latitude_e6 = static_cast(readU32Be(latitude.data())); + decoded.longitude_e6 = static_cast(readU32Be(longitude.data())); + decoded.altitude_cm = static_cast(readU32Be(altitude.data())); + decoded.accuracy_cm = + (static_cast(accuracy[0]) << 8) | accuracy[1]; + decoded.timestamp = timestamp; + decoded.valid = decoded.latitude_e6 >= -90000000 && + decoded.latitude_e6 <= 90000000 && + decoded.longitude_e6 >= -180000000 && + decoded.longitude_e6 <= 180000000; + if (!decoded.valid) + { + return false; + } + *out_location = decoded; + return true; + } + + return false; +} + +bool decodeSidebandTelemetryRequest(const DecodedTextPayload& payload, + SidebandTelemetryRequest* out_request) +{ + if (!out_request) + { + return false; + } + *out_request = SidebandTelemetryRequest{}; + + const DecodedField* commands = findField(payload, kFieldCommands); + if (!commands || commands->encoded_value.empty()) + { + return false; + } + + Cursor cursor{}; + cursor.data = commands->encoded_value.data(); + cursor.len = commands->encoded_value.size(); + size_t command_count = 0; + if (!readArrayHeader(cursor, &command_count)) + { + return false; + } + + for (size_t command_index = 0; command_index < command_count; ++command_index) + { + size_t entry_count = 0; + if (!readMapHeader(cursor, &entry_count)) + { + return false; + } + for (size_t entry_index = 0; entry_index < entry_count; ++entry_index) + { + uint32_t command = 0; + if (!readUint(cursor, &command)) + { + return false; + } + if (command != kSidebandCommandTelemetryRequest) + { + if (!skipObject(cursor)) + { + return false; + } + continue; + } + + SidebandTelemetryRequest decoded{}; + uint8_t next = 0; + if (!peekByte(cursor, &next)) + { + return false; + } + if ((next & 0xF0U) == 0x90U || next == 0xDC || next == 0xDD) + { + size_t request_elements = 0; + if (!readArrayHeader(cursor, &request_elements) || + request_elements < 1 || + !readUint(cursor, &decoded.timebase)) + { + return false; + } + if (request_elements >= 2 && + !readBool(cursor, &decoded.collector_request)) + { + return false; + } + for (size_t index = 2; index < request_elements; ++index) + { + if (!skipObject(cursor)) + { + return false; + } + } + } + else if (!readUint(cursor, &decoded.timebase)) + { + return false; + } + + decoded.valid = true; + *out_request = decoded; + return true; + } + } + + return false; +} + bool decodeAppDataPayload(const uint8_t* data, size_t len, DecodedAppData* out_payload) { if (!data || len < kAppPayloadHeaderLen || !out_payload) @@ -1762,6 +2098,7 @@ bool unpackMessage(const uint8_t* data, size_t len, DecodedMessage* out_message) decoded.has_stamp = payload.has_stamp; decoded.stamp = std::move(payload.stamp); decoded.fields_empty = payload.fields_empty; + decoded.fields = std::move(payload.fields); *out_message = std::move(decoded); return true; } diff --git a/modules/core_chat/src/infra/mesh_peer_directory_core.cpp b/modules/core_chat/src/infra/mesh_peer_directory_core.cpp index 9572c1d6..b9c1706a 100644 --- a/modules/core_chat/src/infra/mesh_peer_directory_core.cpp +++ b/modules/core_chat/src/infra/mesh_peer_directory_core.cpp @@ -97,6 +97,10 @@ struct PersistedMeshPeerEntryV1 uint8_t reticulum_identity_hash2[kReticulumPeerHashSize] = {}; uint8_t reticulum_enc_pub[kMeshPeerReticulumPublicKeyLen] = {}; uint8_t reticulum_sig_pub[kMeshPeerReticulumPublicKeyLen] = {}; + uint8_t reticulum_has_ratchet = 0; + uint8_t reticulum_reserved[3] = {}; + uint32_t reticulum_ratchet_seen_s = 0; + uint8_t reticulum_ratchet_pub[kMeshPeerReticulumRatchetLen] = {}; } TRAILMATE_PACKED; TRAILMATE_PACK_POP @@ -204,6 +208,11 @@ void copyIntoPersisted(PersistedMeshPeerEntryV1& dst, std::memcpy(dst.reticulum_sig_pub, src.reticulum.sig_pub, sizeof(dst.reticulum_sig_pub)); + dst.reticulum_has_ratchet = src.reticulum.has_ratchet ? 1U : 0U; + dst.reticulum_ratchet_seen_s = src.reticulum.ratchet_seen_s; + std::memcpy(dst.reticulum_ratchet_pub, + src.reticulum.ratchet_pub, + sizeof(dst.reticulum_ratchet_pub)); } bool copyFromPersisted(MeshPeerRecord& dst, @@ -266,6 +275,17 @@ bool copyFromPersisted(MeshPeerRecord& dst, std::memcpy(dst.reticulum.sig_pub, src.reticulum_sig_pub, sizeof(dst.reticulum.sig_pub)); + dst.reticulum.has_ratchet = + src.reticulum_has_ratchet != 0 && + meshPeerHasNonZeroBytes(src.reticulum_ratchet_pub, + sizeof(src.reticulum_ratchet_pub)); + if (dst.reticulum.has_ratchet) + { + std::memcpy(dst.reticulum.ratchet_pub, + src.reticulum_ratchet_pub, + sizeof(dst.reticulum.ratchet_pub)); + dst.reticulum.ratchet_seen_s = src.reticulum_ratchet_seen_s; + } return meshPeerRecordIsValid(dst); } diff --git a/modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp b/modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp new file mode 100644 index 00000000..a99aa04c --- /dev/null +++ b/modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp @@ -0,0 +1,605 @@ +/** + * @file lxst_telephony_wire.cpp + * @brief LXST telephony signalling and Codec2 frame MsgPack subset. + */ + +#include "chat/infra/reticulum/lxst_telephony_wire.h" + +#include + +namespace chat::reticulum::lxst +{ +namespace +{ + +constexpr uint32_t kFieldSignalling = 0x00; +constexpr uint32_t kFieldFrames = 0x01; +constexpr std::size_t kMaxSkipDepth = 12; + +struct Cursor +{ + const uint8_t* data = nullptr; + std::size_t len = 0; + std::size_t pos = 0; +}; + +bool appendByte(uint8_t value, + uint8_t* out, + std::size_t out_len, + std::size_t& used) +{ + if (!out || used >= out_len) + { + return false; + } + out[used++] = value; + return true; +} + +bool appendBytes(const uint8_t* data, + std::size_t len, + uint8_t* out, + std::size_t out_len, + std::size_t& used) +{ + if ((!data && len != 0) || !out || used + len > out_len) + { + return false; + } + if (len != 0) + { + std::memcpy(out + used, data, len); + } + used += len; + return true; +} + +bool appendUint(uint32_t value, + uint8_t* out, + std::size_t out_len, + std::size_t& used) +{ + if (value <= 0x7FU) + { + return appendByte(static_cast(value), out, out_len, used); + } + if (value <= 0xFFU) + { + return appendByte(0xCC, out, out_len, used) && + appendByte(static_cast(value), out, out_len, used); + } + if (value <= 0xFFFFU) + { + return appendByte(0xCD, out, out_len, used) && + appendByte(static_cast(value >> 8U), out, out_len, used) && + appendByte(static_cast(value), out, out_len, used); + } + return appendByte(0xCE, out, out_len, used) && + appendByte(static_cast(value >> 24U), out, out_len, used) && + appendByte(static_cast(value >> 16U), out, out_len, used) && + appendByte(static_cast(value >> 8U), out, out_len, used) && + appendByte(static_cast(value), out, out_len, used); +} + +bool appendBinHeader(std::size_t len, + uint8_t* out, + std::size_t out_len, + std::size_t& used) +{ + if (len <= 0xFFU) + { + return appendByte(0xC4, out, out_len, used) && + appendByte(static_cast(len), out, out_len, used); + } + if (len <= 0xFFFFU) + { + return appendByte(0xC5, out, out_len, used) && + appendByte(static_cast(len >> 8U), out, out_len, used) && + appendByte(static_cast(len), out, out_len, used); + } + return false; +} + +bool readByte(Cursor& cursor, uint8_t* out) +{ + if (!out || cursor.pos >= cursor.len) + { + return false; + } + *out = cursor.data[cursor.pos++]; + return true; +} + +bool readCount(Cursor& cursor, + uint8_t marker, + uint8_t fix_prefix, + std::size_t* out_count) +{ + if (!out_count) + { + return false; + } + if ((marker & 0xF0U) == fix_prefix) + { + *out_count = marker & 0x0FU; + return true; + } + if (marker == (fix_prefix == 0x80U ? 0xDEU : 0xDCU)) + { + uint8_t high = 0; + uint8_t low = 0; + if (!readByte(cursor, &high) || !readByte(cursor, &low)) + { + return false; + } + *out_count = (static_cast(high) << 8U) | low; + return true; + } + return false; +} + +bool readMapHeader(Cursor& cursor, std::size_t* out_count) +{ + uint8_t marker = 0; + return readByte(cursor, &marker) && + readCount(cursor, marker, 0x80U, out_count); +} + +bool readArrayHeader(Cursor& cursor, std::size_t* out_count) +{ + uint8_t marker = 0; + return readByte(cursor, &marker) && + readCount(cursor, marker, 0x90U, out_count); +} + +bool readUint(Cursor& cursor, uint32_t* out) +{ + uint8_t marker = 0; + if (!out || !readByte(cursor, &marker)) + { + return false; + } + if (marker <= 0x7FU) + { + *out = marker; + return true; + } + uint8_t bytes[4] = {}; + std::size_t count = 0; + if (marker == 0xCCU) + { + count = 1; + } + else if (marker == 0xCDU) + { + count = 2; + } + else if (marker == 0xCEU) + { + count = 4; + } + else + { + return false; + } + uint32_t value = 0; + for (std::size_t index = 0; index < count; ++index) + { + if (!readByte(cursor, &bytes[index])) + { + return false; + } + value = (value << 8U) | bytes[index]; + } + *out = value; + return true; +} + +bool readBinView(Cursor& cursor, + const uint8_t** out_data, + std::size_t* out_len) +{ + uint8_t marker = 0; + if (!out_data || !out_len || !readByte(cursor, &marker)) + { + return false; + } + std::size_t len = 0; + uint8_t byte = 0; + if (marker == 0xC4U) + { + if (!readByte(cursor, &byte)) + { + return false; + } + len = byte; + } + else if (marker == 0xC5U) + { + uint8_t high = 0; + uint8_t low = 0; + if (!readByte(cursor, &high) || !readByte(cursor, &low)) + { + return false; + } + len = (static_cast(high) << 8U) | low; + } + else + { + return false; + } + if (cursor.pos + len > cursor.len) + { + return false; + } + *out_data = cursor.data + cursor.pos; + *out_len = len; + cursor.pos += len; + return true; +} + +bool skipObject(Cursor& cursor, std::size_t depth = 0) +{ + if (depth >= kMaxSkipDepth || cursor.pos >= cursor.len) + { + return false; + } + const uint8_t marker = cursor.data[cursor.pos]; + if (marker <= 0x7FU || marker >= 0xE0U || marker == 0xC0U || + marker == 0xC2U || marker == 0xC3U) + { + ++cursor.pos; + return true; + } + if (marker == 0xCCU || marker == 0xD0U) + { + cursor.pos += 2; + return cursor.pos <= cursor.len; + } + if (marker == 0xCDU || marker == 0xD1U) + { + cursor.pos += 3; + return cursor.pos <= cursor.len; + } + if (marker == 0xCEU || marker == 0xD2U || marker == 0xCAU) + { + cursor.pos += 5; + return cursor.pos <= cursor.len; + } + if (marker == 0xCFU || marker == 0xD3U || marker == 0xCBU) + { + cursor.pos += 9; + return cursor.pos <= cursor.len; + } + if (marker == 0xC4U || marker == 0xC5U) + { + const uint8_t* ignored = nullptr; + std::size_t ignored_len = 0; + return readBinView(cursor, &ignored, &ignored_len); + } + Cursor nested = cursor; + std::size_t count = 0; + if (readArrayHeader(nested, &count)) + { + cursor = nested; + for (std::size_t index = 0; index < count; ++index) + { + if (!skipObject(cursor, depth + 1)) + { + return false; + } + } + return true; + } + nested = cursor; + if (readMapHeader(nested, &count)) + { + cursor = nested; + for (std::size_t index = 0; index < count * 2U; ++index) + { + if (!skipObject(cursor, depth + 1)) + { + return false; + } + } + return true; + } + return false; +} + +bool decodeCodec2Mode(uint8_t header, + audio_call::Codec2Mode* out_mode) +{ + if (!out_mode) + { + return false; + } + switch (header) + { + case 0x00: + *out_mode = audio_call::Codec2Mode::Mode700C; + return true; + case 0x01: + *out_mode = audio_call::Codec2Mode::Mode1200; + return true; + case 0x02: + *out_mode = audio_call::Codec2Mode::Mode1300; + return true; + case 0x03: + *out_mode = audio_call::Codec2Mode::Mode1400; + return true; + case 0x04: + *out_mode = audio_call::Codec2Mode::Mode1600; + return true; + case 0x05: + *out_mode = audio_call::Codec2Mode::Mode2400; + return true; + case 0x06: + *out_mode = audio_call::Codec2Mode::Mode3200; + return true; + default: + return false; + } +} + +bool encodeCodec2Mode(audio_call::Codec2Mode mode, + uint8_t* out_header) +{ + if (!out_header) + { + return false; + } + switch (mode) + { + case audio_call::Codec2Mode::Mode700C: + *out_header = 0x00; + return true; + case audio_call::Codec2Mode::Mode1200: + *out_header = 0x01; + return true; + case audio_call::Codec2Mode::Mode1300: + *out_header = 0x02; + return true; + case audio_call::Codec2Mode::Mode1400: + *out_header = 0x03; + return true; + case audio_call::Codec2Mode::Mode1600: + *out_header = 0x04; + return true; + case audio_call::Codec2Mode::Mode2400: + *out_header = 0x05; + return true; + case audio_call::Codec2Mode::Mode3200: + *out_header = 0x06; + return true; + case audio_call::Codec2Mode::Mode450: + case audio_call::Codec2Mode::Mode450Pwb: + return false; + } + return false; +} + +bool decodeAudioFrame(const uint8_t* data, + std::size_t len, + DecodedAudioFrame* out_frame) +{ + if (!data || len < 1 || !out_frame) + { + return false; + } + DecodedAudioFrame decoded{}; + decoded.codec = data[0]; + if (decoded.codec == kCodec2) + { + if (len < 2) + { + return false; + } + decoded.codec2_mode_valid = + decodeCodec2Mode(data[1], &decoded.codec2_mode); + decoded.encoded = data + 2; + decoded.encoded_len = len - 2; + } + else + { + decoded.encoded = data + 1; + decoded.encoded_len = len - 1; + } + *out_frame = decoded; + return true; +} + +} // namespace + +bool profileToCodec2Mode(uint16_t profile, + audio_call::Codec2Mode* out_mode) +{ + if (!out_mode) + { + return false; + } + switch (profile) + { + case kProfileBandwidthUltraLow: + *out_mode = audio_call::Codec2Mode::Mode700C; + return true; + case kProfileBandwidthVeryLow: + *out_mode = audio_call::Codec2Mode::Mode1600; + return true; + case kProfileBandwidthLow: + *out_mode = audio_call::Codec2Mode::Mode3200; + return true; + default: + return false; + } +} + +bool codec2ModeToProfile(audio_call::Codec2Mode mode, + uint16_t* out_profile) +{ + if (!out_profile) + { + return false; + } + switch (mode) + { + case audio_call::Codec2Mode::Mode700C: + *out_profile = kProfileBandwidthUltraLow; + return true; + case audio_call::Codec2Mode::Mode1600: + *out_profile = kProfileBandwidthVeryLow; + return true; + case audio_call::Codec2Mode::Mode3200: + *out_profile = kProfileBandwidthLow; + return true; + default: + return false; + } +} + +bool encodeSignalling(uint16_t signal, + uint8_t* out, + std::size_t* inout_len) +{ + if (!out || !inout_len) + { + return false; + } + std::size_t used = 0; + if (!appendByte(0x81, out, *inout_len, used) || + !appendUint(kFieldSignalling, out, *inout_len, used) || + !appendByte(0x91, out, *inout_len, used) || + !appendUint(signal, out, *inout_len, used)) + { + return false; + } + *inout_len = used; + return true; +} + +bool encodeCodec2Frames(audio_call::Codec2Mode mode, + const uint8_t* encoded, + std::size_t encoded_len, + uint8_t* out, + std::size_t* inout_len) +{ + uint8_t mode_header = 0; + if (!encoded || encoded_len == 0 || !out || !inout_len || + !encodeCodec2Mode(mode, &mode_header)) + { + return false; + } + const std::size_t frame_len = encoded_len + 2U; + std::size_t used = 0; + if (!appendByte(0x81, out, *inout_len, used) || + !appendUint(kFieldFrames, out, *inout_len, used) || + !appendBinHeader(frame_len, out, *inout_len, used) || + !appendByte(kCodec2, out, *inout_len, used) || + !appendByte(mode_header, out, *inout_len, used) || + !appendBytes(encoded, encoded_len, out, *inout_len, used)) + { + return false; + } + *inout_len = used; + return true; +} + +bool decodePacket(const uint8_t* data, + std::size_t len, + DecodedPacket* out_packet) +{ + if (!data || len == 0 || !out_packet) + { + return false; + } + *out_packet = DecodedPacket{}; + Cursor cursor{data, len, 0}; + std::size_t field_count = 0; + if (!readMapHeader(cursor, &field_count)) + { + return false; + } + for (std::size_t field_index = 0; field_index < field_count; ++field_index) + { + uint32_t field = 0; + if (!readUint(cursor, &field)) + { + return false; + } + if (field == kFieldSignalling) + { + std::size_t signal_count = 0; + Cursor signalling_cursor = cursor; + if (!readArrayHeader(signalling_cursor, &signal_count)) + { + uint32_t signal = 0; + if (!readUint(cursor, &signal)) + { + return false; + } + out_packet->signals[0] = static_cast(signal); + out_packet->signal_count = 1; + continue; + } + cursor = signalling_cursor; + for (std::size_t index = 0; index < signal_count; ++index) + { + uint32_t signal = 0; + if (!readUint(cursor, &signal)) + { + return false; + } + if (out_packet->signal_count < DecodedPacket::kMaxSignals) + { + out_packet->signals[out_packet->signal_count++] = + static_cast(signal); + } + } + } + else if (field == kFieldFrames) + { + Cursor frame_cursor = cursor; + const uint8_t* frame_data = nullptr; + std::size_t frame_len = 0; + if (readBinView(frame_cursor, &frame_data, &frame_len)) + { + cursor = frame_cursor; + if (out_packet->frame_count < DecodedPacket::kMaxFrames && + decodeAudioFrame(frame_data, + frame_len, + &out_packet->frames[out_packet->frame_count])) + { + ++out_packet->frame_count; + } + continue; + } + + std::size_t frame_count = 0; + if (!readArrayHeader(cursor, &frame_count)) + { + return false; + } + for (std::size_t index = 0; index < frame_count; ++index) + { + if (!readBinView(cursor, &frame_data, &frame_len)) + { + return false; + } + if (out_packet->frame_count < DecodedPacket::kMaxFrames && + decodeAudioFrame(frame_data, + frame_len, + &out_packet->frames[out_packet->frame_count])) + { + ++out_packet->frame_count; + } + } + } + else if (!skipObject(cursor)) + { + return false; + } + } + return out_packet->signal_count != 0 || out_packet->frame_count != 0; +} + +} // namespace chat::reticulum::lxst diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 5b7061ea..7060dd50 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -532,6 +532,7 @@ void ChatService::processIncoming() msg.timestamp = persistable_incoming_timestamp(incoming_text); msg.text = incoming_text.text; msg.reticulum_identity = incoming_text.reticulum_identity; + msg.source_unverified = incoming_text.source_unverified; msg.rx_origin = incoming_text.rx_meta.origin; msg.status = MessageStatus::Incoming; @@ -543,7 +544,7 @@ void ChatService::processIncoming() format_reticulum_hash_prefix(incoming_identity, incoming_dest_hash, sizeof(incoming_dest_hash)); - CHAT_SERVICE_DIAG_LOG("[ChatService][RX] text protocol=%s msg=%lu from=%08lX to=%08lX peer=%08lX dest=%s len=%u encrypted=%u\n", + CHAT_SERVICE_DIAG_LOG("[ChatService][RX] text protocol=%s msg=%lu from=%08lX to=%08lX peer=%08lX dest=%s len=%u encrypted=%u unverified=%u\n", protocol_name(active_protocol_), static_cast(msg.msg_id), static_cast(msg.from), @@ -551,7 +552,8 @@ void ChatService::processIncoming() static_cast(msg.peer), incoming_dest_hash, static_cast(msg.text.size()), - incoming_text.encrypted ? 1U : 0U); + incoming_text.encrypted ? 1U : 0U, + incoming_text.source_unverified ? 1U : 0U); CHAT_SERVICE_LOG("[ChatService] incoming text ch=%u from=%08lX to=%08lX peer=%08lX ts=%lu len=%u\n", static_cast(msg.channel), diff --git a/modules/core_chat/tests/test_mesh_peer_directory_contract.cpp b/modules/core_chat/tests/test_mesh_peer_directory_contract.cpp index 8720bf28..8b289a28 100644 --- a/modules/core_chat/tests/test_mesh_peer_directory_contract.cpp +++ b/modules/core_chat/tests/test_mesh_peer_directory_contract.cpp @@ -446,6 +446,15 @@ chat::MeshPeerRecord makeReticulumPeer(uint8_t seed, record.reticulum.sig_pub[index] = static_cast(seed + 0x20 + index); } + record.reticulum.has_ratchet = true; + for (std::size_t index = 0; + index < chat::kMeshPeerReticulumRatchetLen; + ++index) + { + record.reticulum.ratchet_pub[index] = + static_cast(seed + 0x60 + index); + } + record.reticulum.ratchet_seen_s = seen_s; record.reticulum.delivery = true; return record; } @@ -501,9 +510,36 @@ void protocol_identity_shapes_do_not_collapse() assert(loaded.identity.kind == chat::MeshPeerIdentityKind::ReticulumDestination); assert(loaded.reticulum.has_public_keys); + assert(loaded.reticulum.has_ratchet); assert(loaded.reticulum.delivery); } +void core_persists_reticulum_ratchet() +{ + CountingMeshPeerDirectoryBlobStore blob; + chat::MeshPeerDirectoryCore::Options options{}; + options.auto_save = true; + const auto original = makeReticulumPeer(0x71, "ratchet peer", 55); + + { + chat::MeshPeerDirectoryCore directory(blob, options); + assert(directory.begin().succeeded()); + assert(directory.record(original).succeeded()); + } + + { + chat::MeshPeerDirectoryCore directory(blob, options); + assert(directory.begin().succeeded()); + chat::MeshPeerRecord loaded{}; + assert(directory.find(original.identity, loaded).succeeded()); + assert(loaded.reticulum.has_ratchet); + assert(loaded.reticulum.ratchet_seen_s == 55); + assert(std::memcmp(loaded.reticulum.ratchet_pub, + original.reticulum.ratchet_pub, + chat::kMeshPeerReticulumRatchetLen) == 0); + } +} + void capacity_is_protocol_policy_not_interface_shape() { MemoryMeshPeerDirectory directory; @@ -680,6 +716,7 @@ int main() { upsert_preserves_first_seen_and_updates_peer_facts(); protocol_identity_shapes_do_not_collapse(); + core_persists_reticulum_ratchet(); capacity_is_protocol_policy_not_interface_shape(); search_and_user_flags_are_directory_behaviors(); find_by_node_id_preserves_reticulum_destination_identity(); diff --git a/modules/core_hostlink/include/hostlink/c6/c6_protocol.h b/modules/core_hostlink/include/hostlink/c6/c6_protocol.h index 0da20b85..9c4d2bb1 100644 --- a/modules/core_hostlink/include/hostlink/c6/c6_protocol.h +++ b/modules/core_hostlink/include/hostlink/c6/c6_protocol.h @@ -25,6 +25,7 @@ #define TM_C6_FEATURE_DIAG_LOG (1u << 6) #define TM_C6_FEATURE_HOSTLINK_PING (1u << 7) #define TM_C6_FEATURE_SLAVE_OTA (1u << 8) +#define TM_C6_FEATURE_WIFI_TCP_PROXY (1u << 9) #define TM_C6_P4_FIRMWARE_VERSION_UNKNOWN 0u @@ -34,8 +35,11 @@ #define TM_C6_WIFI_PASSWORD_LEN 64u #define TM_C6_WIFI_SCAN_RESULT_COUNT 6u #define TM_C6_WIFI_TIME_SOURCE_LEN 16u +#define TM_C6_WIFI_TCP_HOST_LEN 64u #define TM_C6_ESPNOW_PAYLOAD_MAX 240u #define TM_C6_BLE_PACKET_HEADER_LEN 4u +#define TM_C6_WIFI_TCP_HEADER_LEN 72u +#define TM_C6_WIFI_TCP_PAYLOAD_MAX (TM_C6_MAX_PAYLOAD - TM_C6_WIFI_TCP_HEADER_LEN) #if defined(_MSC_VER) #define TM_C6_PACKED_BEGIN __pragma(pack(push, 1)) @@ -181,6 +185,17 @@ extern "C" TM_C6_WIFI_EVENT_ERROR = 9, } tm_c6_wifi_event_kind_t; + typedef enum tm_c6_wifi_tcp_operation + { + TM_C6_WIFI_TCP_OPEN = 1, + TM_C6_WIFI_TCP_WRITE = 2, + TM_C6_WIFI_TCP_CLOSE = 3, + TM_C6_WIFI_TCP_OPENED = 4, + TM_C6_WIFI_TCP_DATA = 5, + TM_C6_WIFI_TCP_CLOSED = 6, + TM_C6_WIFI_TCP_ERROR = 7, + } tm_c6_wifi_tcp_operation_t; + TM_C6_PACKED_BEGIN typedef struct TM_C6_PACKED tm_c6_frame_header { @@ -372,6 +387,16 @@ extern "C" char source[TM_C6_WIFI_TIME_SOURCE_LEN]; } tm_c6_wifi_time_sync_t; + typedef struct TM_C6_PACKED tm_c6_wifi_tcp_header + { + uint8_t operation; + uint8_t connection_id; + uint16_t payload_len; + uint16_t port; + uint16_t error_code; + char host[TM_C6_WIFI_TCP_HOST_LEN]; + } tm_c6_wifi_tcp_header_t; + typedef struct TM_C6_PACKED tm_c6_diag_report { uint32_t uptime_ms; @@ -398,4 +423,6 @@ static_assert(sizeof(tm_c6_frame_header_t) == TM_C6_FRAME_HEADER_LEN, "tm_c6_frame_header_t must stay wire-compatible"); static_assert(sizeof(tm_c6_ble_packet_header_t) == TM_C6_BLE_PACKET_HEADER_LEN, "tm_c6_ble_packet_header_t must stay wire-compatible"); +static_assert(sizeof(tm_c6_wifi_tcp_header_t) == TM_C6_WIFI_TCP_HEADER_LEN, + "tm_c6_wifi_tcp_header_t must stay wire-compatible"); #endif diff --git a/modules/core_sys/include/app/app_config.h b/modules/core_sys/include/app/app_config.h index c500426f..9427eeb7 100644 --- a/modules/core_sys/include/app/app_config.h +++ b/modules/core_sys/include/app/app_config.h @@ -189,7 +189,7 @@ struct AppConfig applyMeshCoreFactoryDefaults(); rnode_config = chat::MeshConfig(); applyReticulumFactoryDefaults(); - mesh_protocol = chat::MeshProtocol::Meshtastic; + mesh_protocol = chat::MeshProtocol::Reticulum; applyMeshtasticMqttFactoryDefaults(); node_name[0] = '\0'; short_name[0] = '\0'; diff --git a/modules/core_sys/include/platform/ui/audio/call_notification_adpcm.h b/modules/core_sys/include/platform/ui/audio/call_notification_adpcm.h new file mode 100644 index 00000000..8eba2984 --- /dev/null +++ b/modules/core_sys/include/platform/ui/audio/call_notification_adpcm.h @@ -0,0 +1,286 @@ +#pragma once + +#include +#include + +namespace platform::ui::audio::assets +{ + +// Generated from docs/sound/call.mp3. +// Source is trimmed to ~0.72s, mono, 16 kbps MP3; firmware asset is 11.025 kHz mono IMA ADPCM. +constexpr uint32_t kCallNotificationSampleRateHz = 11025U; +constexpr uint32_t kCallNotificationSampleCount = 8640U; +constexpr int16_t kCallNotificationInitialSample = 0; +constexpr uint8_t kCallNotificationAdpcmData[4320] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x10, 0x99, 0x10, 0x91, 0x00, 0x00, 0x01, 0x09, 0x09, 0x10, 0x11, 0xA0, 0x10, + 0x99, 0x03, 0x9B, 0x33, 0xAA, 0x09, 0x02, 0x99, 0xA9, 0x53, 0xA9, 0x80, 0x4B, 0x92, 0x0D, 0x91, + 0x96, 0x0A, 0x80, 0x28, 0x9B, 0xA3, 0x5B, 0xB2, 0x09, 0x9B, 0x06, 0x09, 0x99, 0x79, 0x90, 0x99, + 0x92, 0x12, 0xBA, 0x3F, 0x81, 0x99, 0xC3, 0x21, 0xD1, 0x3B, 0x05, 0x90, 0xAD, 0x63, 0xA0, 0xB0, + 0x10, 0x04, 0x8D, 0x7A, 0x98, 0x01, 0xD8, 0x02, 0x09, 0x12, 0x09, 0x9B, 0x14, 0x8D, 0x80, 0x86, + 0xC9, 0x12, 0x4A, 0x9A, 0x4A, 0xB1, 0xC5, 0x18, 0x03, 0x0B, 0x1C, 0x83, 0x1C, 0x11, 0x82, 0xAC, + 0xB7, 0x29, 0x81, 0x7A, 0xB8, 0x88, 0x85, 0xB0, 0x19, 0x70, 0xB0, 0x0A, 0x11, 0x03, 0xAD, 0x31, + 0xB0, 0x01, 0x1B, 0x64, 0xAC, 0x01, 0xA3, 0xA1, 0x1B, 0x70, 0xB0, 0x88, 0x81, 0x87, 0x0C, 0x88, + 0x32, 0xD9, 0x11, 0x2A, 0xB8, 0x82, 0xAA, 0x47, 0x1C, 0x0B, 0x33, 0xFB, 0xA2, 0x50, 0xA0, 0x2A, + 0x19, 0xA3, 0x8E, 0x41, 0xB2, 0x0D, 0x86, 0x0A, 0x99, 0x61, 0x98, 0x98, 0x00, 0x93, 0x0B, 0x68, + 0xB9, 0x40, 0xB8, 0x21, 0x8B, 0x06, 0xD9, 0x20, 0x80, 0x39, 0x09, 0xD1, 0x8A, 0x15, 0x90, 0x8B, + 0x21, 0xA5, 0x9E, 0x53, 0x08, 0xCC, 0x22, 0xA1, 0x91, 0x6A, 0x90, 0x99, 0x8C, 0x26, 0x88, 0xAA, + 0x84, 0x9B, 0xA6, 0x7A, 0xA0, 0x29, 0x90, 0x91, 0xA8, 0x58, 0x84, 0x8F, 0x02, 0x38, 0xDA, 0x02, + 0x0B, 0x86, 0x8A, 0x23, 0xAC, 0x49, 0xD2, 0x48, 0xC8, 0x13, 0x8A, 0x89, 0x38, 0x92, 0x4A, 0xBA, + 0xC2, 0x36, 0xCB, 0xB0, 0x72, 0xD3, 0x0C, 0x05, 0x98, 0x9A, 0x23, 0x83, 0xBF, 0x24, 0xA1, 0x0F, + 0x21, 0x90, 0x0B, 0x10, 0x96, 0x9B, 0x48, 0xA4, 0x1B, 0x18, 0xB1, 0x04, 0x8F, 0x03, 0xC1, 0x39, + 0xA0, 0x82, 0x0A, 0x2A, 0x87, 0xAB, 0x78, 0xB2, 0x1A, 0x82, 0xAB, 0x16, 0x0D, 0x23, 0xEA, 0x48, + 0xA0, 0x08, 0x18, 0x28, 0xE2, 0x39, 0x98, 0x91, 0x18, 0x2B, 0xF3, 0x53, 0xA9, 0x9D, 0x24, 0x8A, + 0x18, 0xA3, 0x19, 0x0D, 0x95, 0x1A, 0x20, 0xCB, 0x16, 0x1B, 0xC0, 0x19, 0x17, 0xBC, 0x22, 0x00, + 0x08, 0xA9, 0x99, 0x73, 0xC0, 0x28, 0xB9, 0x75, 0xF1, 0x1C, 0x25, 0xC9, 0x1A, 0x33, 0xD9, 0x1A, + 0x33, 0xC9, 0x0B, 0x35, 0xD8, 0x0A, 0x53, 0xC8, 0x0A, 0x23, 0xB0, 0x9B, 0x63, 0xB1, 0x9C, 0x43, + 0xA1, 0x9C, 0x41, 0xA2, 0x9C, 0x41, 0xA2, 0xBB, 0x51, 0x93, 0xBC, 0x50, 0x82, 0xBB, 0x48, 0x04, + 0xCB, 0x39, 0x06, 0xBA, 0x29, 0x05, 0xB9, 0x2A, 0x15, 0xC9, 0x09, 0x24, 0xB9, 0x1B, 0x34, 0xC9, + 0x0A, 0x34, 0xC9, 0x1B, 0x15, 0xB8, 0x0A, 0x43, 0xB0, 0x9D, 0x34, 0xC0, 0x0B, 0x33, 0xB0, 0xAB, + 0x62, 0xA2, 0xAC, 0x42, 0xA1, 0x9A, 0x30, 0x92, 0xAA, 0x31, 0xB3, 0x9D, 0x51, 0xA2, 0x9C, 0x31, + 0xA1, 0x1B, 0x22, 0xB1, 0xAF, 0x52, 0x82, 0xAE, 0x50, 0x92, 0x8E, 0x14, 0xCA, 0x41, 0xC8, 0x48, + 0xC2, 0x1B, 0x07, 0x9C, 0x23, 0xC9, 0x30, 0xC1, 0x3A, 0xA3, 0x8C, 0x14, 0xBA, 0x42, 0xC8, 0x28, + 0xA2, 0x0B, 0x06, 0xAB, 0x42, 0xC8, 0x38, 0xB3, 0x0E, 0x04, 0x9B, 0x42, 0xC9, 0x48, 0xB1, 0x2A, + 0x94, 0x8C, 0x23, 0xBB, 0x52, 0xC8, 0x38, 0xA1, 0x2C, 0x03, 0x9D, 0x42, 0xC9, 0x30, 0xC2, 0x2B, + 0x84, 0x8C, 0x23, 0xDA, 0x40, 0xB0, 0x3A, 0x94, 0x8C, 0x14, 0xAB, 0x32, 0xD8, 0x38, 0xB2, 0x1C, + 0x05, 0x9C, 0x32, 0xD9, 0x30, 0xB1, 0x2B, 0x85, 0x8C, 0x23, 0xDA, 0x40, 0xB0, 0x3A, 0x94, 0x8C, + 0x14, 0xAB, 0x51, 0xB8, 0x49, 0xB2, 0x1C, 0x04, 0xAB, 0x43, 0xD9, 0x30, 0xC1, 0x19, 0x94, 0x9B, + 0x15, 0xBA, 0x51, 0xC0, 0x29, 0x93, 0x0C, 0x13, 0xAC, 0x42, 0xC8, 0x38, 0xB2, 0x1C, 0x04, 0x9C, + 0x23, 0xD9, 0x30, 0xB1, 0x2B, 0x85, 0x8C, 0x13, 0xBA, 0x51, 0xC0, 0x29, 0x93, 0x8C, 0x13, 0xAA, + 0x41, 0xC8, 0x38, 0xB2, 0x1C, 0x04, 0xBA, 0x32, 0xB9, 0x48, 0xA1, 0x2B, 0x03, 0x9C, 0x41, 0xC8, + 0x19, 0x23, 0x99, 0x90, 0xAE, 0x70, 0x03, 0xAB, 0x98, 0xAA, 0x75, 0x91, 0xAA, 0x88, 0x29, 0x27, + 0xC8, 0x0A, 0x90, 0x68, 0x02, 0xBB, 0x00, 0x8A, 0x45, 0xB1, 0x8C, 0x91, 0x3A, 0x26, 0xC9, 0x09, + 0x99, 0x72, 0x92, 0xAB, 0x00, 0x09, 0x44, 0xB0, 0x8B, 0xB9, 0x71, 0x05, 0xBB, 0x18, 0x88, 0x43, + 0xA1, 0x9B, 0xA9, 0x7A, 0x24, 0xCA, 0x09, 0x90, 0x32, 0x82, 0xAC, 0xA0, 0x3B, 0x37, 0xC0, 0x8A, + 0x98, 0x41, 0x13, 0xCB, 0x98, 0x0A, 0x36, 0xB2, 0x8D, 0x08, 0x28, 0x13, 0xC9, 0x80, 0xBB, 0x73, + 0x84, 0x9C, 0x18, 0x00, 0x11, 0xA9, 0x38, 0xD9, 0x4A, 0x05, 0xAB, 0x21, 0xB8, 0x38, 0x82, 0x1B, + 0xC1, 0x6B, 0x94, 0xAE, 0x73, 0xB8, 0x1A, 0x24, 0xBA, 0x0A, 0x44, 0xB0, 0x9D, 0x62, 0xB1, 0x0C, + 0x42, 0xB8, 0x8A, 0x52, 0xA1, 0xAC, 0x51, 0xA2, 0x9B, 0x41, 0xA2, 0x9C, 0x50, 0x92, 0xBB, 0x58, + 0x83, 0xBB, 0x48, 0x03, 0xBD, 0x48, 0x04, 0xBB, 0x28, 0x14, 0xAB, 0x2A, 0x16, 0xDA, 0x29, 0x15, + 0xBA, 0x19, 0x23, 0xB9, 0x0B, 0x45, 0xD8, 0x1B, 0x25, 0xB9, 0x0A, 0x33, 0xB8, 0x9B, 0x54, 0xC1, + 0x8C, 0x43, 0xB0, 0x8B, 0x33, 0xA0, 0xAC, 0x72, 0xA1, 0x9C, 0x42, 0xA1, 0x9B, 0x41, 0xA2, 0x9C, + 0x50, 0x92, 0xAC, 0x31, 0x03, 0xBD, 0x30, 0x05, 0xBC, 0x30, 0x04, 0xAC, 0x10, 0x13, 0xD9, 0x1A, + 0x26, 0xCB, 0x39, 0x13, 0xAB, 0x09, 0x42, 0xB0, 0x8E, 0x25, 0xC9, 0x19, 0x23, 0xB9, 0x8A, 0x51, + 0xA2, 0xAD, 0x63, 0xB0, 0x0B, 0x24, 0xA9, 0x89, 0x30, 0x83, 0xBE, 0x60, 0x92, 0x8D, 0x31, 0xA0, + 0x8A, 0x20, 0x12, 0xDB, 0x49, 0x83, 0xAC, 0x21, 0x93, 0xAA, 0x29, 0x14, 0xBB, 0x38, 0x04, 0x9C, + 0x08, 0x34, 0xEA, 0x29, 0x03, 0x9D, 0x33, 0xCB, 0x50, 0xC0, 0x49, 0xB3, 0x0E, 0x14, 0xBB, 0x43, + 0xD8, 0x49, 0xA1, 0x2B, 0x04, 0xAC, 0x33, 0xDA, 0x41, 0xC0, 0x3A, 0x83, 0x8D, 0x23, 0xAC, 0x32, + 0xE8, 0x48, 0xA1, 0x0B, 0x05, 0x9B, 0x32, 0xCA, 0x40, 0xC1, 0x3A, 0x83, 0x8E, 0x13, 0xBA, 0x51, + 0xC0, 0x29, 0xA3, 0x0C, 0x05, 0xAB, 0x42, 0xD8, 0x48, 0xA1, 0x1B, 0x04, 0x9C, 0x33, 0xDA, 0x30, + 0xC1, 0x3A, 0x84, 0x8E, 0x13, 0xBA, 0x51, 0xC0, 0x29, 0xA3, 0x0C, 0x05, 0xAB, 0x42, 0xC8, 0x38, + 0xB2, 0x1B, 0x05, 0x8D, 0x32, 0xCA, 0x30, 0xC1, 0x2A, 0x95, 0x8B, 0x14, 0xBB, 0x52, 0xB8, 0x49, + 0xA2, 0x1D, 0x03, 0xBB, 0x34, 0xD9, 0x38, 0xB2, 0x2B, 0x85, 0x8D, 0x23, 0xCA, 0x40, 0xB0, 0x3A, + 0x94, 0x8C, 0x14, 0xBB, 0x52, 0xB8, 0x39, 0xB4, 0x0B, 0x06, 0x9B, 0x32, 0xE9, 0x30, 0xB1, 0x2B, + 0x85, 0x8C, 0x13, 0xBA, 0x51, 0xC0, 0x29, 0x93, 0x0C, 0x13, 0xAC, 0x32, 0xD8, 0x38, 0xA2, 0x0C, + 0x03, 0x9B, 0x24, 0xBA, 0x30, 0xC1, 0x3A, 0x84, 0xAB, 0x22, 0xAA, 0x42, 0xA0, 0x1A, 0xA1, 0x8C, + 0x53, 0x92, 0xB8, 0xDA, 0x2B, 0x47, 0xA1, 0x9B, 0xAA, 0x60, 0x24, 0xB9, 0xBA, 0x0B, 0x56, 0x91, + 0x9A, 0x98, 0x1A, 0x26, 0x91, 0xAB, 0xCB, 0x73, 0x82, 0x9A, 0x90, 0x9B, 0x54, 0x81, 0xA9, 0xAB, + 0x78, 0x03, 0x9A, 0x09, 0xAB, 0x63, 0x83, 0xBA, 0xBA, 0x59, 0x26, 0xB8, 0x9A, 0x98, 0x51, 0x13, + 0xBA, 0xAB, 0x1B, 0x57, 0x91, 0x9C, 0x08, 0x28, 0x23, 0xA0, 0xAC, 0xBB, 0x74, 0x03, 0xCB, 0x09, + 0x00, 0x32, 0x82, 0xBC, 0xBA, 0x70, 0x14, 0xC9, 0x09, 0x91, 0x28, 0x05, 0xBA, 0x88, 0x19, 0x35, + 0xD0, 0x1A, 0x91, 0x1A, 0x15, 0xCA, 0x21, 0xB9, 0x58, 0x93, 0x8C, 0x92, 0x19, 0x13, 0xBE, 0x73, + 0xB0, 0x0C, 0x14, 0xA8, 0x0A, 0x41, 0xA2, 0x9F, 0x41, 0xA2, 0x9C, 0x42, 0xA0, 0xAB, 0x62, 0x91, + 0xBB, 0x51, 0x82, 0xAC, 0x40, 0x82, 0xAD, 0x41, 0x82, 0x9D, 0x20, 0x83, 0xCA, 0x28, 0x04, 0xCB, + 0x48, 0x84, 0xBB, 0x38, 0x14, 0xCA, 0x1A, 0x25, 0xD9, 0x19, 0x15, 0xBA, 0x19, 0x33, 0xB9, 0x8C, + 0x63, 0xC0, 0x0A, 0x43, 0xB9, 0x0B, 0x43, 0xA0, 0xAB, 0x62, 0xA1, 0x9C, 0x53, 0xB0, 0x8B, 0x42, + 0x91, 0xBB, 0x50, 0x93, 0xAD, 0x42, 0xA2, 0x9C, 0x21, 0x83, 0xBC, 0x48, 0x85, 0xBB, 0x58, 0x83, + 0xAC, 0x20, 0x03, 0xCA, 0x2A, 0x16, 0xCA, 0x29, 0x14, 0xBA, 0x29, 0x33, 0xDA, 0x0B, 0x35, 0xD8, + 0x1A, 0x33, 0xD9, 0x09, 0x32, 0xB0, 0x9C, 0x62, 0xB1, 0x8C, 0x33, 0xB0, 0x8C, 0x32, 0xA2, 0xBC, + 0x70, 0x92, 0xAC, 0x51, 0x91, 0x9B, 0x31, 0x81, 0xBB, 0x59, 0x05, 0xBC, 0x40, 0x93, 0xAB, 0x21, + 0x01, 0xC9, 0x2A, 0x17, 0xDA, 0x28, 0x84, 0x9B, 0x21, 0x90, 0x88, 0x99, 0x73, 0xC0, 0x1B, 0x15, + 0xAB, 0x31, 0xA8, 0x29, 0xB0, 0x78, 0xC3, 0x8C, 0x24, 0xBA, 0x41, 0xC8, 0x38, 0xB1, 0x3C, 0x86, + 0xAC, 0x43, 0xB9, 0x48, 0xB1, 0x3B, 0x94, 0x8D, 0x15, 0xCA, 0x31, 0xC0, 0x39, 0xA3, 0x0D, 0x04, + 0x9C, 0x33, 0xD9, 0x38, 0xB1, 0x3B, 0x86, 0x9C, 0x33, 0xCB, 0x41, 0xB0, 0x3B, 0x94, 0x8C, 0x15, + 0xAB, 0x41, 0xC8, 0x38, 0xB3, 0x0C, 0x04, 0xAB, 0x43, 0xC9, 0x48, 0xB1, 0x3B, 0x84, 0x9C, 0x33, + 0xDB, 0x41, 0xB0, 0x3A, 0x93, 0x8E, 0x14, 0xBA, 0x41, 0xC0, 0x39, 0xA2, 0x0C, 0x05, 0xAB, 0x33, + 0xD9, 0x48, 0xB1, 0x1A, 0x85, 0x9B, 0x24, 0xBB, 0x51, 0xB0, 0x4B, 0x93, 0x8D, 0x14, 0xAB, 0x42, + 0xC9, 0x38, 0xB3, 0x1D, 0x84, 0x9B, 0x33, 0xDA, 0x40, 0xC1, 0x2A, 0x84, 0x8C, 0x13, 0xCA, 0x41, + 0xB8, 0x39, 0xA4, 0x0C, 0x04, 0xAA, 0x32, 0xD8, 0x49, 0xA1, 0x1A, 0x84, 0x9C, 0x33, 0xCA, 0x40, + 0xB0, 0x2A, 0x84, 0x8C, 0x23, 0xDA, 0x40, 0xA0, 0x3A, 0x92, 0x0D, 0x13, 0xBB, 0x52, 0xC0, 0x19, + 0x93, 0x1B, 0x04, 0xAC, 0x42, 0xC8, 0x28, 0x92, 0x0C, 0x03, 0x9B, 0x33, 0xE9, 0x38, 0xA1, 0x1B, + 0x05, 0x9A, 0x12, 0xDB, 0x48, 0x03, 0x98, 0xDA, 0x1A, 0x44, 0x91, 0xA9, 0x9B, 0x18, 0x37, 0xA1, + 0xBC, 0x08, 0x40, 0x14, 0xD8, 0x8A, 0x80, 0x42, 0x03, 0xBD, 0x09, 0x18, 0x45, 0xA0, 0x9D, 0x80, + 0x40, 0x13, 0xEA, 0x09, 0x08, 0x42, 0x92, 0xAD, 0x80, 0x30, 0x14, 0xD9, 0x0A, 0x90, 0x52, 0x02, + 0xAD, 0x08, 0x28, 0x24, 0xC8, 0x8B, 0x90, 0x62, 0x02, 0xCB, 0x0A, 0x28, 0x25, 0xA1, 0xAD, 0x88, + 0x51, 0x03, 0xD9, 0x8A, 0x10, 0x43, 0x90, 0x9C, 0x98, 0x28, 0x36, 0xC8, 0x8B, 0x18, 0x32, 0x92, + 0xBB, 0x88, 0x9A, 0x56, 0x91, 0xAB, 0x80, 0x28, 0x14, 0xAA, 0x38, 0xD8, 0x1B, 0x45, 0xA8, 0x9B, + 0x42, 0xB1, 0xAD, 0x73, 0xA4, 0xAE, 0x42, 0xA2, 0x9C, 0x32, 0xA2, 0xAD, 0x40, 0x85, 0xCB, 0x30, + 0x83, 0x9D, 0x30, 0x83, 0xAD, 0x28, 0x15, 0xBB, 0x39, 0x05, 0xCB, 0x20, 0x14, 0xCB, 0x19, 0x15, + 0xB9, 0x1A, 0x15, 0xB9, 0x1B, 0x26, 0xC8, 0x8A, 0x34, 0xB9, 0x1B, 0x24, 0xC8, 0x8A, 0x63, 0xA0, + 0x9C, 0x52, 0xA0, 0x0B, 0x32, 0xA8, 0x8C, 0x51, 0xA2, 0xAC, 0x51, 0xA2, 0x9C, 0x41, 0x91, 0x9C, + 0x31, 0x02, 0xCC, 0x38, 0x04, 0xCB, 0x30, 0x83, 0xBC, 0x30, 0x04, 0xCA, 0x29, 0x15, 0xCA, 0x29, + 0x14, 0xCA, 0x19, 0x24, 0xCA, 0x19, 0x24, 0xC9, 0x0A, 0x34, 0xC9, 0x0A, 0x43, 0xB8, 0x9B, 0x54, + 0xB0, 0x8C, 0x43, 0xB0, 0x8B, 0x43, 0xB1, 0x9C, 0x51, 0xA2, 0xAC, 0x42, 0xA2, 0xAC, 0x42, 0xA2, + 0xAB, 0x50, 0x82, 0xAC, 0x48, 0x03, 0xBC, 0x59, 0x83, 0xBB, 0x48, 0x03, 0xAC, 0x29, 0x15, 0xBA, + 0x1A, 0x16, 0xB9, 0x19, 0x14, 0xBA, 0x2A, 0x24, 0xB9, 0x0B, 0x15, 0xA8, 0x0A, 0x43, 0xB8, 0x8E, + 0x24, 0xA9, 0x29, 0x81, 0x9A, 0x13, 0xA8, 0x48, 0xF1, 0x3A, 0x07, 0xAD, 0x53, 0xC9, 0x59, 0xA1, + 0x1C, 0x04, 0x9C, 0x33, 0xDA, 0x40, 0xB0, 0x2A, 0x95, 0x9B, 0x15, 0xBA, 0x51, 0xC0, 0x29, 0x93, + 0x0C, 0x04, 0xBB, 0x53, 0xC8, 0x38, 0xA1, 0x1C, 0x84, 0x9B, 0x24, 0xCA, 0x40, 0xB0, 0x3A, 0x94, + 0x8C, 0x23, 0xCB, 0x42, 0xC8, 0x49, 0xA2, 0x0C, 0x04, 0xAB, 0x43, 0xC9, 0x30, 0xC1, 0x2A, 0x84, + 0x8C, 0x23, 0xCB, 0x41, 0xC0, 0x29, 0x93, 0x0D, 0x13, 0xAC, 0x43, 0xC9, 0x38, 0xB2, 0x2C, 0x84, + 0xAB, 0x24, 0xD9, 0x40, 0xB0, 0x3A, 0x93, 0x8D, 0x24, 0xBB, 0x41, 0xD0, 0x39, 0xA3, 0x0C, 0x04, + 0xAB, 0x52, 0xC8, 0x38, 0xA1, 0x1C, 0x04, 0x8C, 0x22, 0xCA, 0x40, 0xB1, 0x3B, 0x84, 0x8D, 0x22, + 0xBA, 0x42, 0xC8, 0x39, 0xA3, 0x0D, 0x14, 0xBB, 0x51, 0xB8, 0x38, 0xC3, 0x1B, 0x86, 0x9B, 0x43, + 0xCA, 0x48, 0xA1, 0x1A, 0x83, 0x9C, 0x24, 0xBB, 0x51, 0xB0, 0x2A, 0x84, 0x9B, 0x14, 0xAB, 0x32, + 0xD8, 0x38, 0xB3, 0x0D, 0x13, 0xAA, 0x31, 0xC9, 0x30, 0xB0, 0x4A, 0x94, 0x8D, 0x13, 0xB9, 0x39, + 0x12, 0x81, 0xDD, 0x28, 0x03, 0x28, 0xC2, 0xAD, 0x28, 0x43, 0x85, 0xCD, 0x28, 0x82, 0x10, 0xA3, + 0x9F, 0x20, 0x80, 0x43, 0xEA, 0x1A, 0x03, 0x1A, 0x14, 0xBD, 0x38, 0x80, 0x51, 0xC1, 0x8D, 0x13, + 0x89, 0x32, 0xF9, 0x1A, 0x02, 0x10, 0xA3, 0xAF, 0x31, 0x91, 0x38, 0xD1, 0x8C, 0x22, 0x00, 0x03, + 0xCE, 0x28, 0x03, 0x09, 0x92, 0xAD, 0x20, 0x21, 0x33, 0xED, 0x1A, 0x22, 0x08, 0x01, 0xDB, 0x89, + 0x43, 0x12, 0xDA, 0x8A, 0x11, 0x21, 0x13, 0xDC, 0x8B, 0x43, 0x02, 0xC8, 0x99, 0x08, 0x11, 0x54, + 0xC8, 0x9C, 0x31, 0x01, 0x91, 0x8B, 0x80, 0xBA, 0x74, 0xA4, 0x9C, 0x21, 0x98, 0x21, 0x99, 0x11, + 0xED, 0x58, 0x85, 0x9C, 0x20, 0xA1, 0x09, 0x11, 0x82, 0xFB, 0x39, 0x07, 0xAB, 0x38, 0x83, 0xAC, + 0x20, 0x14, 0xDA, 0x19, 0x15, 0xBA, 0x29, 0x15, 0xCA, 0x19, 0x33, 0xC9, 0x0B, 0x25, 0xC9, 0x19, + 0x43, 0xC9, 0x8A, 0x43, 0xB1, 0x8C, 0x43, 0xC8, 0x0A, 0x52, 0xA0, 0x9C, 0x41, 0xA2, 0x8C, 0x32, + 0xC1, 0x9B, 0x52, 0x93, 0xAD, 0x30, 0x93, 0xAC, 0x31, 0x84, 0xAD, 0x48, 0x84, 0xAB, 0x28, 0x04, + 0xAB, 0x29, 0x16, 0xCA, 0x19, 0x15, 0xBA, 0x18, 0x23, 0xDA, 0x19, 0x34, 0xDA, 0x09, 0x33, 0xC8, + 0x0B, 0x44, 0xB9, 0x1B, 0x34, 0xC8, 0x8B, 0x53, 0xC1, 0x8B, 0x62, 0xA0, 0x8B, 0x42, 0xA0, 0x9B, + 0x52, 0x91, 0xAC, 0x41, 0x93, 0xAD, 0x41, 0x91, 0x9B, 0x30, 0x84, 0xDB, 0x48, 0x83, 0xAC, 0x30, + 0x02, 0xAB, 0x1A, 0x27, 0xDA, 0x29, 0x04, 0xBA, 0x20, 0x12, 0xC9, 0x1A, 0x25, 0xCA, 0x19, 0x23, + 0xC9, 0x0A, 0x26, 0xBA, 0x2A, 0x23, 0xA9, 0xAB, 0x74, 0xB0, 0x0B, 0x34, 0xBA, 0x19, 0x20, 0x82, + 0xBC, 0x61, 0xB1, 0x1B, 0x12, 0x00, 0xEA, 0x48, 0xB3, 0x1E, 0x04, 0x9C, 0x24, 0xBC, 0x62, 0xC8, + 0x38, 0xB2, 0x1C, 0x05, 0x8D, 0x22, 0xC9, 0x40, 0xB0, 0x3A, 0x93, 0x0E, 0x13, 0xAC, 0x42, 0xC8, + 0x39, 0xA3, 0x0C, 0x04, 0xAB, 0x34, 0xDB, 0x40, 0xB1, 0x2B, 0x04, 0x9C, 0x23, 0xCA, 0x41, 0xC0, + 0x3A, 0x94, 0x8C, 0x23, 0xCA, 0x31, 0xC8, 0x48, 0xB2, 0x1C, 0x04, 0xAB, 0x42, 0xB9, 0x48, 0xB0, + 0x4A, 0x93, 0x8E, 0x23, 0xCA, 0x31, 0xC0, 0x39, 0xB3, 0x1D, 0x04, 0xAC, 0x43, 0xB9, 0x49, 0xB2, + 0x2B, 0x85, 0x9C, 0x24, 0xCA, 0x40, 0xB0, 0x29, 0x93, 0x0D, 0x04, 0xBB, 0x53, 0xC8, 0x38, 0xA1, + 0x1C, 0x04, 0x9C, 0x33, 0xEA, 0x30, 0xB1, 0x3B, 0x84, 0x8D, 0x23, 0xCB, 0x51, 0xC0, 0x29, 0x93, + 0x8C, 0x04, 0xAA, 0x42, 0xC9, 0x38, 0xB3, 0x1D, 0x03, 0xAB, 0x53, 0xCA, 0x40, 0xB1, 0x2B, 0x84, + 0x8C, 0x23, 0xBB, 0x51, 0xC0, 0x29, 0x93, 0x8C, 0x14, 0xAB, 0x32, 0xC9, 0x48, 0xC2, 0x1A, 0x03, + 0x9B, 0x32, 0xBB, 0x42, 0xC8, 0x49, 0xA2, 0x0C, 0x13, 0xAA, 0x21, 0x9A, 0x24, 0xEA, 0x28, 0x92, + 0x39, 0xB3, 0x9F, 0x01, 0x20, 0x07, 0xDA, 0x19, 0x01, 0x32, 0xC1, 0x9D, 0x00, 0x31, 0x14, 0xEB, + 0x1A, 0x00, 0x32, 0xA3, 0xAE, 0x88, 0x31, 0x24, 0xE9, 0x0A, 0x08, 0x42, 0x82, 0xBC, 0x98, 0x48, + 0x15, 0xC0, 0x9A, 0x09, 0x63, 0x81, 0xBA, 0xA8, 0x39, 0x27, 0xA1, 0xAC, 0x8A, 0x73, 0x82, 0xB9, + 0x8A, 0x19, 0x35, 0x91, 0xCA, 0xBA, 0x60, 0x14, 0xA8, 0xAB, 0x08, 0x52, 0x02, 0xC8, 0xAB, 0x29, + 0x27, 0xA1, 0xAB, 0x88, 0x30, 0x34, 0xB1, 0xAF, 0x19, 0x24, 0x81, 0xAB, 0x90, 0x9A, 0x74, 0x82, + 0xAC, 0x89, 0x33, 0x01, 0xAA, 0x89, 0x09, 0x21, 0x16, 0xBA, 0x8B, 0x10, 0x44, 0xB2, 0xBD, 0x38, + 0x16, 0xBB, 0x58, 0x93, 0xBD, 0x68, 0x04, 0xBC, 0x38, 0x05, 0xCA, 0x29, 0x15, 0xCA, 0x29, 0x23, + 0xDA, 0x19, 0x24, 0xCA, 0x1A, 0x34, 0xC9, 0x0A, 0x24, 0xC8, 0x0A, 0x34, 0xD8, 0x8A, 0x53, 0xB0, + 0x8B, 0x52, 0xA0, 0x9B, 0x62, 0xA1, 0x9C, 0x41, 0x92, 0xAC, 0x41, 0x91, 0xAB, 0x51, 0x92, 0xCB, + 0x40, 0x83, 0xAC, 0x38, 0x84, 0xBB, 0x48, 0x04, 0xBB, 0x39, 0x06, 0xBA, 0x29, 0x15, 0xCA, 0x29, + 0x23, 0xCA, 0x1A, 0x25, 0xC9, 0x1B, 0x35, 0xCA, 0x1A, 0x24, 0xC9, 0x09, 0x24, 0xB9, 0x0B, 0x44, + 0xB0, 0x9C, 0x43, 0xA0, 0x8C, 0x42, 0xB1, 0x9C, 0x42, 0xA2, 0xBB, 0x51, 0x92, 0xAC, 0x42, 0xA2, + 0xAC, 0x50, 0x82, 0xAC, 0x31, 0x82, 0xAD, 0x31, 0x94, 0x9C, 0x11, 0x82, 0x9A, 0x29, 0x04, 0xBA, + 0x19, 0x25, 0xCA, 0x29, 0x03, 0x9B, 0x18, 0x31, 0xC8, 0x8A, 0x44, 0xB9, 0x0B, 0x54, 0xB8, 0x8A, + 0x31, 0x82, 0xBC, 0x60, 0xA1, 0x8B, 0x53, 0xC1, 0x8C, 0x32, 0x92, 0xAC, 0x31, 0xC2, 0x0B, 0x32, + 0x04, 0xCD, 0x29, 0x04, 0x98, 0x19, 0x02, 0xBD, 0x38, 0x26, 0xA8, 0xBC, 0x48, 0x04, 0x99, 0x09, + 0x80, 0x88, 0x21, 0x02, 0xE9, 0x8B, 0x34, 0x91, 0xAA, 0x01, 0x90, 0x0A, 0x35, 0xE1, 0x1A, 0xB2, + 0x3C, 0x17, 0xDB, 0x10, 0x12, 0x98, 0x98, 0x9B, 0x54, 0xA0, 0x1A, 0x02, 0xCD, 0x60, 0x93, 0xAB, + 0x1A, 0x35, 0xB8, 0x1D, 0x02, 0xA9, 0x20, 0x02, 0x88, 0xBE, 0x41, 0x02, 0x98, 0x98, 0x9B, 0x42, + 0xB1, 0x70, 0xF3, 0x0A, 0x20, 0x02, 0xC8, 0x1A, 0x83, 0x08, 0x89, 0x82, 0xA8, 0x48, 0xC4, 0x2C, + 0x94, 0xAB, 0x34, 0x98, 0xB1, 0x9E, 0x62, 0xA3, 0x9F, 0x42, 0xA0, 0x8C, 0x42, 0xA1, 0x9D, 0x32, + 0x92, 0xAC, 0x30, 0x12, 0xCC, 0x38, 0x05, 0xBB, 0x28, 0x12, 0xA8, 0x19, 0x12, 0xDA, 0x1B, 0x26, + 0x90, 0xAC, 0x31, 0xC1, 0x59, 0x92, 0x9C, 0x21, 0x00, 0xA9, 0x18, 0x24, 0xFC, 0x48, 0x83, 0xCB, + 0x29, 0x24, 0xB9, 0x0A, 0x32, 0xD8, 0x1A, 0x25, 0xC0, 0x8D, 0x42, 0x91, 0x9B, 0x10, 0x82, 0x9A, + 0x32, 0xB2, 0xBE, 0x41, 0x02, 0x99, 0x89, 0x90, 0x08, 0x42, 0x82, 0xEB, 0x2A, 0x13, 0xAB, 0x71, + 0xB4, 0x9F, 0x32, 0x91, 0xAA, 0x20, 0x13, 0xEB, 0x39, 0x06, 0xAC, 0x30, 0x02, 0xC9, 0x0A, 0x34, + 0xC9, 0x19, 0x04, 0xBA, 0x00, 0x41, 0x92, 0xAF, 0x41, 0x91, 0x0B, 0x21, 0xB8, 0x89, 0x41, 0x02, + 0xEB, 0x39, 0x83, 0x9B, 0x33, 0xD9, 0x09, 0x10, 0x33, 0xE0, 0x8C, 0x13, 0x99, 0x52, 0xC0, 0x8B, + 0x02, 0x21, 0x02, 0xBD, 0x20, 0xBA, 0x74, 0xA2, 0x9D, 0x11, 0x00, 0x01, 0x99, 0x80, 0xCC, 0x72, + 0x92, 0xAA, 0x88, 0x31, 0x82, 0xAB, 0x23, 0xDD, 0x40, 0x03, 0xBB, 0x98, 0x50, 0x83, 0xBC, 0x52, + 0xE8, 0x39, 0x04, 0xBA, 0x88, 0x38, 0x06, 0xCA, 0x20, 0xB0, 0x39, 0x14, 0xC8, 0xA9, 0x29, 0x27, + 0xC8, 0x19, 0xA1, 0x3B, 0x16, 0xA9, 0xA8, 0x0B, 0x46, 0xA0, 0x8A, 0xA0, 0x29, 0x34, 0xA8, 0xB8, + 0x9F, 0x53, 0x91, 0x8A, 0xA1, 0x8A, 0x43, 0x82, 0x99, 0xCD, 0x41, 0x82, 0x0A, 0x92, 0xAD, 0x22, + 0x02, 0x08, 0xD9, 0x29, 0x91, 0x58, 0x84, 0xAF, 0x21, 0x81, 0x00, 0xA9, 0x89, 0x12, 0x30, 0x86, + 0xCE, 0x30, 0x02, 0x09, 0xC0, 0x0A, 0x12, 0x00, 0x14, 0xCD, 0x29, 0x12, 0x38, 0xD2, 0x8E, 0x11, + 0x10, 0x03, 0xCD, 0x20, 0x90, 0x38, 0x06, 0xBC, 0x29, 0x32, 0x81, 0xDC, 0x30, 0xB2, 0x1C, 0x25, + 0xCA, 0x89, 0x32, 0x83, 0xDC, 0x28, 0x03, 0xAA, 0x40, 0xA1, 0xAC, 0x58, 0x14, 0xDA, 0x1A, 0x14, + 0xA9, 0x10, 0x02, 0xEA, 0x1A, 0x17, 0xB8, 0x0A, 0x23, 0xB9, 0x28, 0x14, 0xEB, 0x0A, 0x45, 0xA8, + 0x9B, 0x33, 0xB9, 0x28, 0x23, 0xE9, 0x8C, 0x63, 0xA1, 0x9C, 0x32, 0xB8, 0x2A, 0x33, 0xB0, 0xAF, + 0x50, 0x83, 0xAC, 0x31, 0xA0, 0x8B, 0x43, 0x92, 0xBD, 0x48, 0x04, 0xCB, 0x41, 0xA0, 0x8B, 0x42, + 0x81, 0xCB, 0x39, 0x14, 0xBA, 0x48, 0xA2, 0x9D, 0x52, 0x91, 0xBB, 0x38, 0x05, 0xBA, 0x58, 0x82, + 0xAD, 0x40, 0x83, 0xCA, 0x19, 0x33, 0xD9, 0x19, 0x05, 0xCA, 0x28, 0x14, 0xCA, 0x19, 0x23, 0xB8, + 0x8C, 0x44, 0xC8, 0x0A, 0x24, 0xC8, 0x0A, 0x33, 0xB8, 0x9A, 0x43, 0xB1, 0x9D, 0x44, 0xC0, 0x8B, + 0x34, 0xB0, 0x9C, 0x42, 0x91, 0xBB, 0x61, 0x92, 0xAD, 0x52, 0x91, 0x9C, 0x30, 0x82, 0xBA, 0x38, + 0x05, 0xAD, 0x40, 0x83, 0xAD, 0x20, 0x12, 0xB9, 0x1A, 0x24, 0xEA, 0x29, 0x06, 0xBA, 0x18, 0x13, + 0xA8, 0x8B, 0x25, 0xC9, 0x2B, 0x17, 0xC9, 0x19, 0x13, 0x98, 0x9C, 0x43, 0xB0, 0x0C, 0x34, 0xC9, + 0x0B, 0x34, 0xA8, 0xBB, 0x62, 0x92, 0xAC, 0x42, 0xB1, 0x9B, 0x53, 0x91, 0xAC, 0x48, 0x03, 0xDB, + 0x38, 0x03, 0xBC, 0x50, 0x92, 0xBA, 0x39, 0x16, 0xBA, 0x3B, 0x15, 0xBB, 0x48, 0x83, 0xCB, 0x39, + 0x23, 0xC0, 0x8E, 0x14, 0xA8, 0x2A, 0x85, 0xAA, 0x19, 0x24, 0xA0, 0xAD, 0x42, 0xA1, 0x0B, 0x24, + 0xBB, 0x09, 0x43, 0x83, 0xDE, 0x20, 0x03, 0xAA, 0x20, 0xA0, 0xA9, 0x68, 0x13, 0xFA, 0x1A, 0x14, + 0xA8, 0x18, 0xA0, 0x89, 0x40, 0x03, 0xD9, 0x8D, 0x42, 0x81, 0x0B, 0x90, 0x9B, 0x73, 0x92, 0x99, + 0xBC, 0x52, 0x03, 0x9C, 0x10, 0xAA, 0x41, 0x83, 0x8A, 0xFB, 0x39, 0x17, 0xBA, 0x10, 0xA0, 0x38, + 0x84, 0x0B, 0xD0, 0x1B, 0x37, 0xC9, 0x29, 0x90, 0x19, 0x13, 0x0B, 0xB2, 0xAF, 0x54, 0xB1, 0x8A, + 0x80, 0x10, 0x13, 0x9C, 0x02, 0xBC, 0x61, 0x83, 0x9C, 0x98, 0x30, 0x05, 0xCB, 0x31, 0xC9, 0x39, + 0x16, 0xAA, 0x9A, 0x31, 0x23, 0xF9, 0x28, 0xA8, 0x1A, 0x45, 0xB8, 0xAA, 0x38, 0x14, 0xA8, 0x2A, + 0xD9, 0x18, 0x53, 0xC2, 0x9B, 0x29, 0x14, 0x80, 0x8A, 0xBA, 0x68, 0x11, 0xA2, 0x9E, 0x28, 0x94, + 0x3B, 0x84, 0xBC, 0x59, 0x81, 0x02, 0xAD, 0x30, 0x93, 0x0F, 0x13, 0xC9, 0x3A, 0x84, 0x09, 0xBA, + 0x61, 0x82, 0x9F, 0x32, 0xB8, 0x1A, 0x04, 0x08, 0xDB, 0x40, 0x12, 0xCB, 0x21, 0xA8, 0x29, 0x04, + 0x9A, 0xB0, 0x2B, 0x46, 0xB8, 0x1B, 0x80, 0x80, 0x63, 0xC8, 0x99, 0x11, 0x31, 0xB3, 0x9F, 0x18, + 0x01, 0x63, 0xC8, 0xAA, 0x20, 0x23, 0xA4, 0x9D, 0x01, 0xA9, 0x72, 0xA2, 0xAA, 0x1B, 0x44, 0xA2, + 0x8E, 0x02, 0xA9, 0x59, 0x84, 0xAB, 0x89, 0x32, 0x15, 0xBD, 0x31, 0xA0, 0x0B, 0x17, 0xA8, 0xBA, + 0x48, 0x16, 0xC9, 0x29, 0x81, 0x8A, 0x43, 0x98, 0xDA, 0x3A, 0x35, 0xD9, 0x19, 0x82, 0x9A, 0x41, + 0x81, 0xCA, 0x2A, 0x33, 0xD2, 0x8C, 0x33, 0xD9, 0x48, 0x91, 0x99, 0x9B, 0x64, 0xB2, 0x9C, 0x32, + 0xA8, 0x1A, 0x12, 0x90, 0xAC, 0x60, 0x04, 0xAE, 0x20, 0x03, 0xAA, 0x08, 0x12, 0xDA, 0x49, 0x15, + 0xCC, 0x28, 0x13, 0xAA, 0x18, 0x01, 0xDA, 0x58, 0x23, 0xFB, 0x19, 0x22, 0xA9, 0x28, 0x00, 0xCA, + 0x3A, 0x37, 0xD8, 0x0C, 0x32, 0xB0, 0x2A, 0x02, 0xBA, 0x2B, 0x37, 0xC0, 0x8D, 0x32, 0x91, 0x8B, + 0x11, 0xB9, 0x2C, 0x46, 0xB0, 0x9E, 0x31, 0x01, 0x9A, 0x01, 0xA8, 0x0B, 0x36, 0xA1, 0xBE, 0x40, + 0x12, 0x9A, 0x18, 0xA0, 0x9C, 0x73, 0x92, 0xCB, 0x20, 0x11, 0x98, 0x18, 0xB0, 0xBC, 0x73, 0x83, + 0xDA, 0x19, 0x04, 0xB9, 0x40, 0x93, 0xBD, 0x58, 0x12, 0xC9, 0x1A, 0x03, 0xB8, 0x5A, 0x14, 0xCC, + 0x39, 0x13, 0xB9, 0x0B, 0x25, 0xC9, 0x2A, 0x24, 0xBA, 0x2C, 0x15, 0xB9, 0x1A, 0x33, 0xD8, 0x0B, + 0x35, 0xCA, 0x1A, 0x34, 0xC8, 0x9B, 0x34, 0xB1, 0xAB, 0x63, 0xA8, 0x8A, 0x43, 0xB8, 0xAB, 0x54, + 0xA2, 0xBC, 0x51, 0x81, 0xAB, 0x42, 0xB1, 0x9D, 0x52, 0x91, 0xBA, 0x49, 0x03, 0xBA, 0x40, 0xA2, + 0x9F, 0x31, 0x94, 0xAA, 0x2A, 0x13, 0xB8, 0x3B, 0x07, 0xCB, 0x30, 0x13, 0xCA, 0x8B, 0x35, 0x98, + 0xAA, 0x23, 0xCB, 0x69, 0x83, 0x99, 0xBB, 0x50, 0x82, 0x99, 0x31, 0xEB, 0x19, 0x15, 0x8A, 0xA1, + 0x0A, 0x32, 0x93, 0xAD, 0xA8, 0x25, 0x11, 0x9C, 0x98, 0x89, 0x25, 0x11, 0x9E, 0xB1, 0x31, 0x41, + 0xA9, 0xCA, 0x1A, 0x37, 0xB0, 0x8D, 0x11, 0xB1, 0x10, 0x36, 0xAE, 0x98, 0x15, 0x28, 0x8D, 0x92, + 0xB8, 0x61, 0x18, 0xC9, 0x00, 0x11, 0x39, 0xE0, 0x38, 0x91, 0x99, 0x91, 0x1D, 0x00, 0x17, 0x91, + 0x8B, 0xBF, 0x22, 0x14, 0x40, 0xD9, 0xB9, 0x0A, 0x37, 0xA8, 0x00, 0x99, 0x10, 0x9C, 0x34, 0xAB, + 0x23, 0xAB, 0x06, 0x9E, 0x14, 0x9A, 0x22, 0x8B, 0xA2, 0x9F, 0x17, 0x89, 0x99, 0x39, 0x85, 0x8D, + 0x22, 0x98, 0xE0, 0x39, 0x96, 0x99, 0x11, 0xB9, 0x95, 0x1B, 0x34, 0xB0, 0xFB, 0x89, 0x75, 0x89, + 0xA8, 0x08, 0x48, 0x80, 0x18, 0xBC, 0x84, 0x20, 0x5A, 0xE1, 0x90, 0x1A, 0x33, 0x81, 0xF8, 0x0A, + 0x40, 0x80, 0x81, 0x1C, 0xA9, 0x40, 0x60, 0xEA, 0x1A, 0x67, 0xB8, 0x9E, 0x20, 0x27, 0xB8, 0x9C, + 0x00, 0x51, 0x93, 0xDA, 0x08, 0x19, 0x24, 0xA1, 0x8E, 0x80, 0x28, 0x33, 0xE9, 0x89, 0x88, 0x42, + 0x83, 0xBC, 0x88, 0x29, 0x36, 0xC0, 0x8B, 0x98, 0x51, 0x04, 0xCA, 0x89, 0x08, 0x25, 0x92, 0x9E, + 0x88, 0x30, 0x24, 0xDA, 0x89, 0x80, 0x52, 0x92, 0xAC, 0x08, 0x28, 0x34, 0xC8, 0x0C, 0x98, 0x41, + 0x13, 0xDB, 0x09, 0x09, 0x34, 0x92, 0x9E, 0xA0, 0x48, 0x23, 0xD8, 0x0A, 0x99, 0x63, 0x82, 0xAC, + 0x90, 0x39, 0x25, 0xB8, 0x8B, 0xA8, 0x52, 0x04, 0xCA, 0x08, 0x19, 0x22, 0x92, 0x9E, 0x80, 0x20, + 0x22, 0xD9, 0x29, 0x99, 0x10, 0x14, 0x8A, 0xA2, 0xBD, 0x49, 0x27, 0xC9, 0x8A, 0x25, 0xD9, 0x2A, + 0x26, 0xCA, 0x1A, 0x24, 0xC9, 0x1A, 0x25, 0xD9, 0x1A, 0x24, 0xC8, 0x0A, 0x33, 0xD8, 0x0A, 0x53, + 0xB8, 0x8B, 0x53, 0xB1, 0x8C, 0x42, 0xB0, 0x8C, 0x53, 0xA0, 0x9C, 0x42, 0xA1, 0x9B, 0x41, 0xA3, + 0xAD, 0x51, 0x92, 0xAC, 0x31, 0x93, 0xAC, 0x48, 0x03, 0xBD, 0x40, 0x03, 0xAD, 0x38, 0x13, 0xBC, + 0x28, 0x14, 0xDA, 0x29, 0x24, 0xDA, 0x19, 0x14, 0xB9, 0x1A, 0x34, 0xCA, 0x0A, 0x34, 0xC8, 0x0B, + 0x34, 0xD8, 0x0A, 0x43, 0xB8, 0x8B, 0x53, 0xB1, 0x9C, 0x53, 0xB0, 0x8B, 0x52, 0xA1, 0xAB, 0x51, + 0xA3, 0x9D, 0x31, 0xA3, 0xAC, 0x40, 0x93, 0xBC, 0x50, 0x83, 0xBC, 0x30, 0x04, 0xCB, 0x28, 0x05, + 0xBA, 0x29, 0x15, 0xCA, 0x29, 0x14, 0xCA, 0x29, 0x33, 0xDB, 0x09, 0x34, 0xC9, 0x0A, 0x24, 0xC8, + 0x0A, 0x43, 0xB8, 0x8C, 0x53, 0xB0, 0x8B, 0x53, 0xB0, 0x8B, 0x43, 0xB1, 0x9C, 0x42, 0xA1, 0xAB, + 0x52, 0xA1, 0xAB, 0x52, 0x92, 0xAC, 0x31, 0x92, 0x9C, 0x31, 0xA2, 0xAB, 0x30, 0x05, 0xCB, 0x30, + 0xA2, 0x0C, 0x14, 0xAB, 0x42, 0xEB, 0x71, 0xC0, 0x3B, 0x86, 0x9C, 0x24, 0xCA, 0x40, 0xB0, 0x4A, + 0xA3, 0x8D, 0x14, 0xAB, 0x42, 0xC8, 0x49, 0xA1, 0x1B, 0x05, 0x9C, 0x32, 0xC9, 0x30, 0xB1, 0x2C, + 0x84, 0x8C, 0x23, 0xCB, 0x41, 0xC0, 0x39, 0xA3, 0x0D, 0x13, 0xBB, 0x53, 0xD8, 0x38, 0xA1, 0x1C, + 0x85, 0x9B, 0x33, 0xDA, 0x40, 0xB0, 0x29, 0x93, 0x8D, 0x14, 0xBA, 0x51, 0xB8, 0x49, 0xA2, 0x0C, + 0x04, 0xAB, 0x43, 0xC9, 0x48, 0xB1, 0x2B, 0x85, 0x9B, 0x33, 0xDB, 0x41, 0xC0, 0x39, 0x92, 0x0D, + 0x13, 0xBB, 0x52, 0xC8, 0x38, 0xB2, 0x1C, 0x85, 0x9B, 0x33, 0xDA, 0x40, 0xB0, 0x3A, 0x94, 0x8C, + 0x14, 0xAB, 0x51, 0xB8, 0x39, 0xA3, 0x0D, 0x04, 0xAB, 0x43, 0xD9, 0x48, 0xA1, 0x1B, 0x04, 0x9C, + 0x33, 0xCB, 0x50, 0xB0, 0x3A, 0x94, 0x8C, 0x23, 0xCB, 0x42, 0xC8, 0x28, 0xA3, 0x0D, 0x04, 0xAA, + 0x41, 0xC8, 0x38, 0xA1, 0x2B, 0x84, 0x9C, 0x33, 0xCA, 0x30, 0xC2, 0x1A, 0x03, 0x9C, 0x33, 0xCB, + 0x30, 0xB2, 0x2B, 0x84, 0x8C, 0x22, 0xBA, 0x32, 0xC0, 0x2A, 0x93, 0x8C, 0x42, 0x90, 0x89, 0xAB, + 0x39, 0x46, 0x92, 0xCD, 0x19, 0x31, 0x23, 0xD8, 0xAC, 0x01, 0x51, 0x03, 0xFB, 0x19, 0x10, 0x11, + 0xA2, 0x9D, 0x80, 0x20, 0x44, 0xE9, 0x0A, 0x11, 0x01, 0x01, 0xCA, 0x99, 0x40, 0x34, 0xE8, 0x8B, + 0x11, 0x12, 0x11, 0xDB, 0x99, 0x49, 0x35, 0xB8, 0x8E, 0x80, 0x31, 0x13, 0xCC, 0x19, 0x99, 0x44, + 0xA3, 0x9E, 0x81, 0x18, 0x32, 0xD0, 0x0A, 0xB8, 0x71, 0x83, 0xBC, 0x11, 0x88, 0x30, 0x94, 0x8D, + 0x80, 0x08, 0x25, 0xD9, 0x19, 0x81, 0x08, 0x03, 0xBB, 0x10, 0x98, 0x72, 0xC2, 0x8B, 0x22, 0xA8, + 0x31, 0xD8, 0x18, 0x81, 0x0C, 0x25, 0xAB, 0x18, 0x90, 0x20, 0xC0, 0x60, 0xB2, 0x9F, 0x62, 0xA8, + 0x89, 0x23, 0xB9, 0x9B, 0x65, 0xB1, 0xAC, 0x63, 0xB0, 0x8A, 0x34, 0xB9, 0x8B, 0x63, 0xA1, 0xAC, + 0x61, 0xA1, 0x9B, 0x43, 0xB1, 0x8C, 0x31, 0x83, 0xBD, 0x59, 0x84, 0xBB, 0x50, 0x92, 0xBB, 0x31, + 0x04, 0xCB, 0x29, 0x16, 0xDA, 0x28, 0x04, 0xAB, 0x29, 0x14, 0xBA, 0x1A, 0x35, 0xD9, 0x1A, 0x24, + 0xD9, 0x19, 0x32, 0xB9, 0x8B, 0x35, 0xC0, 0x0C, 0x33, 0xD0, 0x8A, 0x33, 0xB1, 0x9D, 0x42, 0xB2, + 0x9C, 0x42, 0xA1, 0x9C, 0x32, 0x92, 0xAD, 0x40, 0x93, 0xAC, 0x40, 0x93, 0xAC, 0x40, 0x82, 0xBB, + 0x30, 0x05, 0xBB, 0x4A, 0x05, 0xCA, 0x39, 0x04, 0xAB, 0x28, 0x13, 0xD9, 0x0A, 0x35, 0xD9, 0x1A, + 0x24, 0xC9, 0x09, 0x23, 0xB8, 0x8B, 0x44, 0xC0, 0x0B, 0x43, 0xB0, 0x8C, 0x42, 0xA0, 0x9B, 0x43, + 0xA1, 0x9D, 0x42, 0xB2, 0x9C, 0x41, 0x91, 0x9B, 0x31, 0x93, 0xBD, 0x41, 0x93, 0xAC, 0x31, 0x92, + 0xBB, 0x40, 0x04, 0xDB, 0x38, 0x83, 0x9C, 0x21, 0x92, 0xAB, 0x38, 0x14, 0xDA, 0x38, 0xA2, 0x0C, + 0x22, 0x18, 0xE8, 0x1C, 0x17, 0xAC, 0x43, 0xD9, 0x38, 0xB2, 0x3C, 0x95, 0x8D, 0x14, 0xCA, 0x41, + 0xC0, 0x29, 0xA3, 0x1B, 0x04, 0xBB, 0x43, 0xD8, 0x38, 0xA2, 0x0C, 0x04, 0xAB, 0x43, 0xC9, 0x48, + 0xB1, 0x2A, 0x94, 0x9B, 0x24, 0xBB, 0x42, 0xD0, 0x28, 0xA2, 0x0B, 0x05, 0xAB, 0x43, 0xC9, 0x48, + 0xA1, 0x1B, 0x84, 0x9B, 0x24, 0xCA, 0x40, 0xB0, 0x3A, 0x94, 0x8C, 0x04, 0xAA, 0x32, 0xC8, 0x49, + 0xA1, 0x1B, 0x04, 0xAB, 0x33, 0xD9, 0x30, 0xB1, 0x3B, 0x93, 0x8D, 0x14, 0xAA, 0x21, 0xB0, 0x39, + 0x91, 0x1B, 0x02, 0x9A, 0x02, 0x88, 0x08, 0x80, 0x18, 0x98, 0x01, 0x99, 0x21, 0x90, 0x3B, 0xB0, + 0x91, 0x29, 0xB3, 0x21, 0x9B, 0x29, 0xB1, 0x5A, 0xC2, 0x29, 0x91, 0x19, 0x21, 0x01, 0xBB, 0x43, + 0xAB, 0x33, 0xA1, 0x3B, 0xB2, 0x09, 0x11, 0x01, 0x99, 0x19, 0x92, 0x09, 0x91, 0x09, 0x11, 0x90, + 0x99, 0x12, 0xB9, 0x31, 0xB1, 0x3A, 0xA3, 0x0B, 0x23, 0xBB, 0x33, 0xB9, 0x29, 0x91, 0x1B, 0x13, + 0x9A, 0x10, 0x91, 0x09, 0x11, 0x99, 0x10, 0x01, 0x0A, 0x12, 0xA9, 0x21, 0xA0, 0x19, 0x92, 0x09}; + +} // namespace platform::ui::audio::assets diff --git a/modules/core_sys/include/platform/ui/audio/call_notification_tone.h b/modules/core_sys/include/platform/ui/audio/call_notification_tone.h new file mode 100644 index 00000000..2c773808 --- /dev/null +++ b/modules/core_sys/include/platform/ui/audio/call_notification_tone.h @@ -0,0 +1,254 @@ +#pragma once + +#include "platform/ui/audio/call_notification_adpcm.h" + +#include +#include + +namespace platform::ui::audio::call_notification +{ + +namespace asset = ::platform::ui::audio::assets; + +constexpr uint32_t kSourceSampleRateHz = asset::kCallNotificationSampleRateHz; +constexpr uint32_t kPlaybackSampleRateHz = 44100U; +constexpr uint8_t kChannels = 2U; +static_assert(kPlaybackSampleRateHz % kSourceSampleRateHz == 0, + "Call notification asset sample rate must divide the playback sample rate"); +constexpr uint8_t kSampleRepeat = + static_cast(kPlaybackSampleRateHz / kSourceSampleRateHz); + +constexpr int8_t kImaAdpcmIndexTable[16] = { + -1, + -1, + -1, + -1, + 2, + 4, + 6, + 8, + -1, + -1, + -1, + -1, + 2, + 4, + 6, + 8, +}; + +constexpr int16_t kImaAdpcmStepTable[89] = { + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 17, + 19, + 21, + 23, + 25, + 28, + 31, + 34, + 37, + 41, + 45, + 50, + 55, + 60, + 66, + 73, + 80, + 88, + 97, + 107, + 118, + 130, + 143, + 157, + 173, + 190, + 209, + 230, + 253, + 279, + 307, + 337, + 371, + 408, + 449, + 494, + 544, + 598, + 658, + 724, + 796, + 876, + 963, + 1060, + 1166, + 1282, + 1411, + 1552, + 1707, + 1878, + 2066, + 2272, + 2499, + 2749, + 3024, + 3327, + 3660, + 4026, + 4428, + 4871, + 5358, + 5894, + 6484, + 7132, + 7845, + 8630, + 9493, + 10442, + 11487, + 12635, + 13899, + 15289, + 16818, + 18500, + 20350, + 22385, + 24623, + 27086, + 29794, + 32767, +}; + +struct AdpcmPlaybackState +{ + int16_t predictor = asset::kCallNotificationInitialSample; + int16_t current_sample = asset::kCallNotificationInitialSample; + uint8_t step_index = 0U; + uint8_t repeat_remaining = 0U; + uint32_t sample_index = 0U; +}; + +inline bool hasMore(const AdpcmPlaybackState& state) +{ + return state.sample_index < asset::kCallNotificationSampleCount || + state.repeat_remaining > 0U; +} + +inline int16_t clampPcmSample(int32_t sample) +{ + if (sample > 32767) + { + return 32767; + } + if (sample < -32768) + { + return -32768; + } + return static_cast(sample); +} + +inline int16_t nextSourceSample(AdpcmPlaybackState& state) +{ + if (state.sample_index == 0U) + { + state.sample_index = 1U; + return state.current_sample; + } + + const uint32_t nibble_index = state.sample_index - 1U; + const uint8_t encoded = asset::kCallNotificationAdpcmData[nibble_index / 2U]; + const uint8_t code = (nibble_index & 1U) == 0U ? (encoded & 0x0FU) : (encoded >> 4); + const int16_t step = kImaAdpcmStepTable[state.step_index]; + + int32_t diff = step >> 3; + if ((code & 0x01U) != 0U) + { + diff += step >> 2; + } + if ((code & 0x02U) != 0U) + { + diff += step >> 1; + } + if ((code & 0x04U) != 0U) + { + diff += step; + } + + const int32_t predictor = (code & 0x08U) != 0U + ? static_cast(state.predictor) - diff + : static_cast(state.predictor) + diff; + state.predictor = clampPcmSample(predictor); + state.current_sample = state.predictor; + + const int16_t next_index = + static_cast(state.step_index) + kImaAdpcmIndexTable[code & 0x0FU]; + if (next_index < 0) + { + state.step_index = 0U; + } + else if (next_index > 88) + { + state.step_index = 88U; + } + else + { + state.step_index = static_cast(next_index); + } + + ++state.sample_index; + return state.current_sample; +} + +inline bool nextPlaybackSample(AdpcmPlaybackState& state, int16_t& sample) +{ + if (!hasMore(state)) + { + return false; + } + + if (state.repeat_remaining == 0U) + { + if (state.sample_index >= asset::kCallNotificationSampleCount) + { + return false; + } + state.current_sample = nextSourceSample(state); + state.repeat_remaining = kSampleRepeat; + } + + sample = state.current_sample; + --state.repeat_remaining; + return true; +} + +inline uint16_t fillStereoInterleaved(AdpcmPlaybackState& state, + int16_t* pcm, + uint16_t max_frames) +{ + uint16_t frames = 0U; + while (frames < max_frames) + { + int16_t sample = 0; + if (!nextPlaybackSample(state, sample)) + { + break; + } + + pcm[frames * kChannels] = sample; + pcm[(frames * kChannels) + 1U] = sample; + ++frames; + } + return frames; +} + +} // namespace platform::ui::audio::call_notification diff --git a/modules/core_sys/include/platform/ui/reticulum_call_runtime.h b/modules/core_sys/include/platform/ui/reticulum_call_runtime.h index 1a982ca2..dba31dd0 100644 --- a/modules/core_sys/include/platform/ui/reticulum_call_runtime.h +++ b/modules/core_sys/include/platform/ui/reticulum_call_runtime.h @@ -34,6 +34,20 @@ enum class RealtimePhase : uint8_t ClosingCall, }; +enum class WireProfile : uint8_t +{ + SidebandLxst = 0, + MeshChatCallAudio = 1, +}; + +enum class Codec2Mode : uint8_t +{ + Mode700C = 0, + Mode1200 = 1, + Mode1600 = 2, + Mode3200 = 3, +}; + struct Peer { uint8_t link_id[kHashSize] = {}; @@ -41,6 +55,8 @@ struct Peer uint8_t identity_hash[kHashSize] = {}; const char* display_name = nullptr; bool incoming = false; + WireProfile wire_profile = WireProfile::SidebandLxst; + Codec2Mode codec2_mode = Codec2Mode::Mode3200; }; struct Snapshot @@ -51,7 +67,10 @@ struct Snapshot State state = State::Idle; bool incoming = false; bool accepted = false; + bool link_active = false; bool media_active = false; + WireProfile wire_profile = WireProfile::SidebandLxst; + Codec2Mode codec2_mode = Codec2Mode::Mode3200; RealtimePhase realtime_phase = RealtimePhase::Idle; uint8_t link_id[kHashSize] = {}; uint8_t peer_destination_hash[kHashSize] = {}; @@ -76,6 +95,8 @@ struct MediaHooks bool (*is_supported)() = nullptr; bool (*start)() = nullptr; void (*stop)() = nullptr; + uint8_t (*speaker_volume)() = nullptr; + void (*set_speaker_volume)(uint8_t volume_percent) = nullptr; }; struct RealtimeHooks @@ -89,6 +110,8 @@ struct RealtimeHooks void set_media_hooks(const MediaHooks& hooks); void set_realtime_hooks(const RealtimeHooks& hooks); +uint8_t speaker_volume(); +void set_speaker_volume(uint8_t volume_percent); void set_wifi_ready(bool ready); bool begin_incoming(const Peer& peer); @@ -96,6 +119,8 @@ bool begin_outgoing(const Peer& peer); void update_peer(const Peer& peer); void mark_link_active(const uint8_t link_id[kHashSize]); void notify_link_closed(const uint8_t link_id[kHashSize]); +void notify_media_failed(); +void service_ui_runtime(); bool accept(); void reject(); diff --git a/modules/core_sys/include/platform/ui/screen_runtime.h b/modules/core_sys/include/platform/ui/screen_runtime.h index 0f449a9b..323e3873 100644 --- a/modules/core_sys/include/platform/ui/screen_runtime.h +++ b/modules/core_sys/include/platform/ui/screen_runtime.h @@ -28,6 +28,7 @@ bool is_sleep_disabled(); bool is_saver_active(); void wake_saver(); void enter_from_saver(); +void wake_for_modal(); void update_user_activity(); void disable_sleep(); void enable_sleep(); diff --git a/modules/core_sys/include/platform/ui/wifi_access_runtime.h b/modules/core_sys/include/platform/ui/wifi_access_runtime.h index c5305d25..7daa325e 100644 --- a/modules/core_sys/include/platform/ui/wifi_access_runtime.h +++ b/modules/core_sys/include/platform/ui/wifi_access_runtime.h @@ -25,6 +25,7 @@ enum class AccessKind : std::uint8_t HttpDownload, OtaDownload, ReticulumGatewayCallAudio, + ReticulumGatewayCallControl, }; enum class Priority : std::uint8_t @@ -153,7 +154,8 @@ bool begin_foreground_download(const Request& request, Decision* out_decision = void end_foreground_download(Client client, AccessKind kind); TrafficBudget traffic_budget(Client client, Priority priority, - const std::uint8_t* call_link_id = nullptr); + const std::uint8_t* call_link_id = nullptr, + AccessKind access_kind = AccessKind::LongLivedSocket); RuntimeStatus status(); const char* client_name(Client client); diff --git a/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp b/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp index 8e081b8e..f112ac37 100644 --- a/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp +++ b/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp @@ -28,6 +28,7 @@ struct RuntimeState bool hangup_requested = false; bool closing_keep_exclusive = false; bool sleep_wake_lease = false; + bool sleep_wake_lease_applied = false; sys::RingBuffer inbound; sys::RingBuffer outbound; }; @@ -83,6 +84,11 @@ uint32_t now_ms() return sys::millis_now(); } +uint8_t clamp_volume(uint8_t volume_percent) +{ + return volume_percent > 100U ? 100U : volume_percent; +} + void clear_queues_locked() { s_state.inbound.clear(); @@ -106,6 +112,8 @@ void apply_peer_locked(const Peer& peer) sizeof(s_state.snapshot.peer_name), peer.display_name); } + s_state.snapshot.wire_profile = peer.wire_profile; + s_state.snapshot.codec2_mode = peer.codec2_mode; s_state.snapshot.updated_ms = now_ms(); } @@ -148,18 +156,6 @@ void end_realtime_outside_lock(RealtimeHooks hooks, } } -void acquire_sleep_wake_lease_outside_lock() -{ - screen::disable_sleep(); - screen::enter_from_saver(); - screen::update_user_activity(); -} - -void release_sleep_wake_lease_outside_lock() -{ - screen::enable_sleep(); -} - bool acquire_sleep_wake_lease_locked() { if (s_state.sleep_wake_lease) @@ -180,6 +176,8 @@ bool release_sleep_wake_lease_locked() return true; } +void close_call(State final_state, bool request_remote_close); + bool start_media_if_needed() { MediaHooks hooks{}; @@ -191,7 +189,15 @@ bool start_media_if_needed() hooks.is_supported ? hooks.is_supported() : false; should_start = s_state.snapshot.accepted && s_state.snapshot.state == State::Active && + s_state.snapshot.link_active && !s_state.snapshot.media_active; + if (should_start) + { + // Reserve media ownership before the task starts so a newly scheduled + // task cannot observe a transient inactive state and exit immediately. + s_state.snapshot.media_active = true; + s_state.snapshot.updated_ms = now_ms(); + } } if (!should_start) { @@ -199,37 +205,9 @@ bool start_media_if_needed() } if (!hooks.start || !hooks.start()) { - RealtimeHooks realtime_hooks{}; - uint8_t link_id[kHashSize] = {}; - bool end_realtime = false; - bool release_sleep_wake = false; - { - std::lock_guard lock(s_state.mutex); - realtime_hooks = s_state.realtime_hooks; - copy_hash(link_id, s_state.snapshot.link_id); - end_realtime = s_state.snapshot.realtime_phase != RealtimePhase::Idle && - !hash_is_empty(link_id); - s_state.snapshot.state = State::Failed; - s_state.snapshot.media_active = false; - s_state.snapshot.accepted = false; - s_state.snapshot.realtime_phase = RealtimePhase::Idle; - s_state.closing_keep_exclusive = false; - release_sleep_wake = release_sleep_wake_lease_locked(); - s_state.snapshot.updated_ms = now_ms(); - } - if (end_realtime) - { - end_realtime_outside_lock(realtime_hooks, link_id); - } - if (release_sleep_wake) - { - release_sleep_wake_lease_outside_lock(); - } + close_call(State::Failed, true); return false; } - std::lock_guard lock(s_state.mutex); - s_state.snapshot.media_active = true; - s_state.snapshot.updated_ms = now_ms(); return true; } @@ -242,7 +220,6 @@ void close_call(State final_state, bool request_remote_close) bool end_realtime = false; bool begin_closing = false; bool keep_exclusive = false; - bool release_sleep_wake = false; { std::lock_guard lock(s_state.mutex); hooks = s_state.hooks; @@ -255,6 +232,7 @@ void close_call(State final_state, bool request_remote_close) s_state.snapshot.realtime_phase == RealtimePhase::ClosingCall; s_state.snapshot.media_active = false; s_state.snapshot.accepted = false; + s_state.snapshot.link_active = false; s_state.snapshot.state = final_state; s_state.snapshot.updated_ms = now_ms(); s_state.hangup_requested = request_remote_close && @@ -272,7 +250,7 @@ void close_call(State final_state, bool request_remote_close) !hash_is_empty(link_id); s_state.snapshot.realtime_phase = RealtimePhase::Idle; s_state.closing_keep_exclusive = false; - release_sleep_wake = release_sleep_wake_lease_locked(); + (void)release_sleep_wake_lease_locked(); } clear_queues_locked(); } @@ -288,10 +266,6 @@ void close_call(State final_state, bool request_remote_close) { end_realtime_outside_lock(realtime_hooks, link_id); } - if (release_sleep_wake) - { - release_sleep_wake_lease_outside_lock(); - } } bool enqueue_packet(sys::RingBuffer& queue, @@ -342,6 +316,29 @@ void set_media_hooks(const MediaHooks& hooks) hooks.is_supported ? hooks.is_supported() : false; } +uint8_t speaker_volume() +{ + MediaHooks hooks{}; + { + std::lock_guard lock(s_state.mutex); + hooks = s_state.hooks; + } + return hooks.speaker_volume ? hooks.speaker_volume() : 0U; +} + +void set_speaker_volume(uint8_t volume_percent) +{ + MediaHooks hooks{}; + { + std::lock_guard lock(s_state.mutex); + hooks = s_state.hooks; + } + if (hooks.set_speaker_volume) + { + hooks.set_speaker_volume(clamp_volume(volume_percent)); + } +} + void set_realtime_hooks(const RealtimeHooks& hooks) { std::lock_guard lock(s_state.mutex); @@ -364,7 +361,6 @@ bool begin_incoming(const Peer& peer) RealtimeHooks realtime_hooks{}; uint8_t link_id[kHashSize] = {}; bool start_ringing = false; - bool acquire_sleep_wake = false; { std::lock_guard lock(s_state.mutex); if (s_state.snapshot.state == State::Active || @@ -386,13 +382,9 @@ bool begin_incoming(const Peer& peer) clear_queues_locked(); s_state.hangup_requested = false; s_state.closing_keep_exclusive = false; - acquire_sleep_wake = acquire_sleep_wake_lease_locked(); + (void)acquire_sleep_wake_lease_locked(); start_ringing = true; } - if (acquire_sleep_wake) - { - acquire_sleep_wake_lease_outside_lock(); - } if (start_ringing) { (void)begin_ringing_outside_lock(realtime_hooks, link_id); @@ -422,7 +414,6 @@ bool begin_outgoing(const Peer& peer) return false; } bool started = false; - bool acquire_sleep_wake = false; { std::lock_guard lock(s_state.mutex); if (s_state.snapshot.state == State::Active || @@ -449,16 +440,12 @@ bool begin_outgoing(const Peer& peer) clear_queues_locked(); s_state.hangup_requested = false; s_state.closing_keep_exclusive = false; - acquire_sleep_wake = acquire_sleep_wake_lease_locked(); + (void)acquire_sleep_wake_lease_locked(); started = true; } } if (started) { - if (acquire_sleep_wake) - { - acquire_sleep_wake_lease_outside_lock(); - } return true; } end_realtime_outside_lock(realtime_hooks, peer.link_id); @@ -492,6 +479,13 @@ void mark_link_active(const uint8_t link_id[kHashSize]) { return; } + if (s_state.snapshot.state != State::Incoming && + s_state.snapshot.state != State::Outgoing && + s_state.snapshot.state != State::Active) + { + return; + } + s_state.snapshot.link_active = true; if (s_state.snapshot.state == State::Outgoing) { s_state.snapshot.accepted = true; @@ -506,6 +500,12 @@ void mark_link_active(const uint8_t link_id[kHashSize]) s_state.snapshot.realtime_phase = RealtimePhase::ActiveCall; should_start = true; } + else if (s_state.snapshot.state == State::Active && + s_state.snapshot.accepted) + { + s_state.snapshot.realtime_phase = RealtimePhase::ActiveCall; + should_start = !s_state.snapshot.media_active; + } s_state.snapshot.updated_ms = now_ms(); } if (should_start) @@ -530,6 +530,47 @@ void notify_link_closed(const uint8_t link_id[kHashSize]) close_call(State::Ended, false); } +void notify_media_failed() +{ + bool should_close = false; + { + std::lock_guard lock(s_state.mutex); + should_close = s_state.snapshot.state == State::Active && + s_state.snapshot.accepted && + s_state.snapshot.media_active; + } + if (should_close) + { + close_call(State::Failed, true); + } +} + +void service_ui_runtime() +{ + bool apply_lease = false; + bool release_lease = false; + { + std::lock_guard lock(s_state.mutex); + if (s_state.sleep_wake_lease_applied != s_state.sleep_wake_lease) + { + s_state.sleep_wake_lease_applied = s_state.sleep_wake_lease; + apply_lease = s_state.sleep_wake_lease; + release_lease = !s_state.sleep_wake_lease; + } + } + + if (apply_lease) + { + screen::wake_for_modal(); + screen::disable_sleep(); + screen::update_user_activity(); + } + else if (release_lease) + { + screen::enable_sleep(); + } +} + bool accept() { RealtimeHooks realtime_hooks{}; @@ -546,10 +587,10 @@ bool accept() } if (!begin_exclusive_outside_lock(realtime_hooks, link_id)) { + close_call(State::Failed, true); return false; } bool accepted = false; - bool acquire_sleep_wake = false; { std::lock_guard lock(s_state.mutex); if (!hashes_equal(s_state.snapshot.link_id, link_id) || @@ -562,18 +603,16 @@ bool accept() { s_state.snapshot.accepted = true; s_state.snapshot.state = State::Active; - s_state.snapshot.realtime_phase = RealtimePhase::AcceptedStarting; + s_state.snapshot.realtime_phase = + s_state.snapshot.link_active ? RealtimePhase::ActiveCall + : RealtimePhase::AcceptedStarting; s_state.snapshot.updated_ms = now_ms(); - acquire_sleep_wake = acquire_sleep_wake_lease_locked(); + (void)acquire_sleep_wake_lease_locked(); accepted = true; } } if (accepted) { - if (acquire_sleep_wake) - { - acquire_sleep_wake_lease_outside_lock(); - } return start_media_if_needed(); } end_realtime_outside_lock(realtime_hooks, link_id); @@ -633,6 +672,7 @@ bool media_should_run() std::lock_guard lock(s_state.mutex); return s_state.snapshot.state == State::Active && s_state.snapshot.accepted && + s_state.snapshot.link_active && s_state.snapshot.media_active; } diff --git a/modules/core_sys/tests/test_app_config_defaults.cpp b/modules/core_sys/tests/test_app_config_defaults.cpp new file mode 100644 index 00000000..f992d07b --- /dev/null +++ b/modules/core_sys/tests/test_app_config_defaults.cpp @@ -0,0 +1,15 @@ +#include "app/app_config.h" + +#include + +int main() +{ + const app::AppConfig config{}; + assert(config.mesh_protocol == chat::MeshProtocol::Reticulum); + assert(config.reticulumConfig().reticulum_wifi_gateway_enabled); + assert(config.reticulumConfig().reticulum_wifi_gateway_port == 4242); + assert(config.reticulumConfig().reticulum_call_wire_profile == + chat::ReticulumCallWireProfile::SidebandLxst); + assert(!config.reticulumConfig().reticulum_allow_location_requests); + return 0; +} diff --git a/modules/core_sys/tests/test_reticulum_call_runtime.cpp b/modules/core_sys/tests/test_reticulum_call_runtime.cpp new file mode 100644 index 00000000..c27e1c0a --- /dev/null +++ b/modules/core_sys/tests/test_reticulum_call_runtime.cpp @@ -0,0 +1,305 @@ +#include "platform/ui/reticulum_call_runtime.h" +#include "platform/ui/screen_runtime.h" +#include "sys/clock.h" + +#include +#include +#include +#include + +namespace +{ + +struct Harness +{ + bool media_supported = true; + bool media_start_ok = true; + bool exclusive_ok = true; + int media_start_count = 0; + int media_stop_count = 0; + int ringing_count = 0; + int exclusive_count = 0; + int closing_count = 0; + int realtime_end_count = 0; + int modal_wake_count = 0; + int sleep_disable_count = 0; + int sleep_enable_count = 0; + int activity_count = 0; + uint32_t now_ms = 100; +}; + +Harness g; + +uint32_t fake_millis() +{ + return ++g.now_ms; +} + +bool media_supported() +{ + return g.media_supported; +} + +bool media_start() +{ + ++g.media_start_count; + return g.media_start_ok; +} + +void media_stop() +{ + ++g.media_stop_count; +} + +bool begin_ringing(const uint8_t*) +{ + ++g.ringing_count; + return true; +} + +bool begin_exclusive(const uint8_t*) +{ + ++g.exclusive_count; + return g.exclusive_ok; +} + +void begin_closing(const uint8_t*, bool) +{ + ++g.closing_count; +} + +void end_realtime(const uint8_t*) +{ + ++g.realtime_end_count; +} + +void configure_hooks() +{ + platform::ui::reticulum_call::MediaHooks media{}; + media.is_supported = media_supported; + media.start = media_start; + media.stop = media_stop; + platform::ui::reticulum_call::set_media_hooks(media); + + platform::ui::reticulum_call::RealtimeHooks realtime{}; + realtime.begin_ringing = begin_ringing; + realtime.begin_exclusive = begin_exclusive; + realtime.begin_closing = begin_closing; + realtime.end = end_realtime; + platform::ui::reticulum_call::set_realtime_hooks(realtime); +} + +platform::ui::reticulum_call::Peer make_peer(uint8_t seed, bool incoming) +{ + platform::ui::reticulum_call::Peer peer{}; + for (std::size_t index = 0; + index < platform::ui::reticulum_call::kHashSize; + ++index) + { + peer.link_id[index] = static_cast(seed + index); + peer.destination_hash[index] = static_cast(seed + 0x20U + index); + } + peer.display_name = incoming ? "Incoming peer" : "Outgoing peer"; + peer.incoming = incoming; + return peer; +} + +void complete_local_close(const platform::ui::reticulum_call::Peer& peer) +{ + uint8_t link_id[platform::ui::reticulum_call::kHashSize] = {}; + assert(platform::ui::reticulum_call::consume_hangup_request(link_id)); + assert(std::memcmp(link_id, peer.link_id, sizeof(link_id)) == 0); + platform::ui::reticulum_call::notify_link_closed(peer.link_id); + platform::ui::reticulum_call::service_ui_runtime(); + assert(platform::ui::reticulum_call::realtime_phase() == + platform::ui::reticulum_call::RealtimePhase::Idle); +} + +void test_link_active_before_accept() +{ + const auto peer = make_peer(0x10, true); + assert(platform::ui::reticulum_call::begin_incoming(peer)); + platform::ui::reticulum_call::service_ui_runtime(); + + auto snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Incoming); + assert(!snapshot.accepted); + assert(!snapshot.link_active); + assert(!snapshot.media_active); + assert(snapshot.wire_profile == + platform::ui::reticulum_call::WireProfile::SidebandLxst); + assert(snapshot.codec2_mode == + platform::ui::reticulum_call::Codec2Mode::Mode3200); + + platform::ui::reticulum_call::mark_link_active(peer.link_id); + snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Incoming); + assert(snapshot.link_active); + assert(g.media_start_count == 0); + + assert(platform::ui::reticulum_call::accept()); + snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Active); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::ActiveCall); + assert(snapshot.media_active); + assert(g.media_start_count == 1); + + platform::ui::reticulum_call::hangup(); + assert(g.media_stop_count == 1); + assert(platform::ui::reticulum_call::realtime_phase() == + platform::ui::reticulum_call::RealtimePhase::ClosingCall); + complete_local_close(peer); +} + +void test_accept_before_link_active() +{ + const int starts_before = g.media_start_count; + const auto peer = make_peer(0x30, true); + assert(platform::ui::reticulum_call::begin_incoming(peer)); + platform::ui::reticulum_call::service_ui_runtime(); + assert(platform::ui::reticulum_call::accept()); + + auto snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Active); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::AcceptedStarting); + assert(!snapshot.link_active); + assert(!snapshot.media_active); + assert(g.media_start_count == starts_before); + + platform::ui::reticulum_call::mark_link_active(peer.link_id); + snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.link_active); + assert(snapshot.media_active); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::ActiveCall); + assert(g.media_start_count == starts_before + 1); + + platform::ui::reticulum_call::hangup(); + complete_local_close(peer); +} + +void test_exclusive_denial_closes_call() +{ + const auto peer = make_peer(0x50, true); + assert(platform::ui::reticulum_call::begin_incoming(peer)); + platform::ui::reticulum_call::service_ui_runtime(); + g.exclusive_ok = false; + assert(!platform::ui::reticulum_call::accept()); + + const auto snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Failed); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::ClosingCall); + assert(!snapshot.accepted); + assert(!snapshot.media_active); + + g.exclusive_ok = true; + complete_local_close(peer); +} + +void test_media_start_failure_closes_link() +{ + const auto peer = make_peer(0x70, true); + assert(platform::ui::reticulum_call::begin_incoming(peer)); + platform::ui::reticulum_call::service_ui_runtime(); + platform::ui::reticulum_call::mark_link_active(peer.link_id); + g.media_start_ok = false; + const int stops_before = g.media_stop_count; + assert(!platform::ui::reticulum_call::accept()); + + const auto snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Failed); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::ClosingCall); + assert(!snapshot.accepted); + assert(!snapshot.link_active); + assert(!snapshot.media_active); + assert(g.media_stop_count == stops_before + 1); + + g.media_start_ok = true; + complete_local_close(peer); +} + +void test_outgoing_hard_preempt_waits_for_link() +{ + const int starts_before = g.media_start_count; + auto peer = make_peer(0x90, false); + peer.wire_profile = + platform::ui::reticulum_call::WireProfile::MeshChatCallAudio; + peer.codec2_mode = + platform::ui::reticulum_call::Codec2Mode::Mode1200; + assert(platform::ui::reticulum_call::begin_outgoing(peer)); + platform::ui::reticulum_call::service_ui_runtime(); + + auto snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Outgoing); + assert(snapshot.accepted); + assert(!snapshot.link_active); + assert(!snapshot.media_active); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::AcceptedStarting); + assert(snapshot.wire_profile == + platform::ui::reticulum_call::WireProfile::MeshChatCallAudio); + assert(snapshot.codec2_mode == + platform::ui::reticulum_call::Codec2Mode::Mode1200); + + platform::ui::reticulum_call::mark_link_active(peer.link_id); + snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Active); + assert(snapshot.media_active); + assert(g.media_start_count == starts_before + 1); + + platform::ui::reticulum_call::notify_media_failed(); + snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Failed); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::ClosingCall); + complete_local_close(peer); +} + +} // namespace + +namespace platform::ui::screen +{ + +void wake_for_modal() +{ + ++g.modal_wake_count; +} + +void disable_sleep() +{ + ++g.sleep_disable_count; +} + +void enable_sleep() +{ + ++g.sleep_enable_count; +} + +void update_user_activity() +{ + ++g.activity_count; +} + +} // namespace platform::ui::screen + +int main() +{ + sys::set_millis_provider(fake_millis); + configure_hooks(); + + test_link_active_before_accept(); + test_accept_before_link_active(); + test_exclusive_denial_closes_call(); + test_media_start_failure_closes_link(); + test_outgoing_hard_preempt_waits_for_link(); + + assert(g.modal_wake_count == 5); + assert(g.sleep_disable_count == 5); + assert(g.sleep_enable_count == 5); + assert(g.activity_count == 5); + return 0; +} diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h index 948960bf..efb44c50 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h @@ -88,6 +88,7 @@ struct MessageRow MessageDeliveryState delivery = MessageDeliveryState::Unknown; MessageFailureKind failure = MessageFailureKind::None; MessageIngressTransport ingress_transport = MessageIngressTransport::Unknown; + bool source_unverified = false; uint32_t sender_node_id = 0; ui::FixedText<160> text; diff --git a/modules/ui_shared/include/ui/screens/chat/chat_conversation_styles.h b/modules/ui_shared/include/ui/screens/chat/chat_conversation_styles.h index 90f1fb3c..6f6b355b 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_conversation_styles.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_conversation_styles.h @@ -18,7 +18,7 @@ void apply_reply_label(lv_obj_t* label); // message row + bubble void apply_message_row(lv_obj_t* row); -void apply_bubble(lv_obj_t* bubble, bool is_self); +void apply_bubble(lv_obj_t* bubble, bool is_self, bool source_unverified = false); void apply_bubble_text(lv_obj_t* label); void apply_bubble_time(lv_obj_t* label); void apply_bubble_status(lv_obj_t* label); diff --git a/modules/ui_shared/include/ui/screens/network/micron_markup_contract.h b/modules/ui_shared/include/ui/screens/network/micron_markup_contract.h index dc4eba57..06480790 100644 --- a/modules/ui_shared/include/ui/screens/network/micron_markup_contract.h +++ b/modules/ui_shared/include/ui/screens/network/micron_markup_contract.h @@ -7,6 +7,7 @@ namespace ui::screens::network::micron { constexpr std::size_t kMaxAnchorNameBytes = 48; +constexpr std::size_t kMaxLinkTargetBytes = 160; constexpr std::size_t kMaxTableCells = 6; enum class Align : std::uint8_t @@ -47,9 +48,28 @@ struct LinkFields char anchor[kMaxAnchorNameBytes] = {}; }; +struct ColorToken +{ + bool valid = false; + std::uint32_t rgb = 0; + std::size_t consumed = 0; +}; + +struct PartialReference +{ + bool valid = false; + bool has_display_hint = false; + std::uint16_t display_hint = 0; + LinkTargetKind target_kind = LinkTargetKind::Unknown; + char target[kMaxLinkTargetBytes] = {}; +}; + bool anchor_name_char(char ch); void slug_from_text(const char* text, char* out, std::size_t out_len); +bool parse_color_text(const char* text, std::size_t len, std::uint32_t* out); +ColorToken parse_color_token(const char* line, std::size_t len, std::size_t index); + TableOpenOptions parse_table_opening_tag(const char* line); std::size_t parse_table_cells(const char* row_text, Span* cells, @@ -64,7 +84,9 @@ bool append_anchor_to_target(const char* target, char* out, std::size_t out_len); LinkTargetKind classify_link_target(const char* target); +const char* link_target_kind_label(LinkTargetKind kind); bool extract_partial_url(const char* line, char* out, std::size_t out_len); +PartialReference parse_partial_reference(const char* line); } // namespace ui::screens::network::micron diff --git a/modules/ui_shared/include/ui/screens/settings/settings_state.h b/modules/ui_shared/include/ui/screens/settings/settings_state.h index a4d857e6..dba4e0c5 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -78,6 +78,8 @@ enum class SettingId : std::uint16_t RtWifiPort, RtWifiAuto, RtAnonymousPeer, + RtCallWire, + RtLocationRequests, NetUsePreset, NetPreset, NetBw, @@ -252,6 +254,8 @@ struct SettingsData bool rt_wifi_gateway_enabled = true; bool rt_wifi_auto_connect = true; bool rt_anonymous_peer = false; + int rt_call_wire = 0; + bool rt_location_requests = false; char rt_display_name[32] = "--"; char rt_identity_hash[36] = "--"; char rt_lxmf_address[36] = "--"; diff --git a/modules/ui_shared/include/ui/widgets/foreground_operation_overlay.h b/modules/ui_shared/include/ui/widgets/foreground_operation_overlay.h index 61508f64..3d418646 100644 --- a/modules/ui_shared/include/ui/widgets/foreground_operation_overlay.h +++ b/modules/ui_shared/include/ui/widgets/foreground_operation_overlay.h @@ -12,6 +12,7 @@ enum class Slot : std::uint8_t I18nFontLoad, RouteImage, SettingsAction, + ReticulumPing, Count, }; diff --git a/modules/ui_shared/include/ui/widgets/reticulum_ping_overlay.h b/modules/ui_shared/include/ui/widgets/reticulum_ping_overlay.h new file mode 100644 index 00000000..1c68c80d --- /dev/null +++ b/modules/ui_shared/include/ui/widgets/reticulum_ping_overlay.h @@ -0,0 +1,25 @@ +#pragma once + +#include "chat/domain/reticulum_identity.h" + +#include + +namespace ui::widgets::reticulum_ping +{ + +void show_loading( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize], + const char* display_name); +void show_send_failure(const char* detail); +void show_delivered( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize], + std::uint32_t latency_ms, + std::uint8_t hops); +void show_timeout( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize], + std::uint32_t elapsed_ms); +void dismiss(); +bool active_for( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize]); + +} // namespace ui::widgets::reticulum_ping diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp index 6c7bbe7c..1e89e8d2 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp @@ -13,6 +13,10 @@ #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" +#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif + namespace ui::menu::dashboard { namespace diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp index 35039d6c..2a5aed24 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp @@ -13,6 +13,10 @@ #include "ui/presentation_sources/runtime_gps_status_source.h" #include "ui_presentation/gps/gps_status_model.h" +#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif + namespace ui::menu::dashboard { namespace diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp index f95af01f..94a91087 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp @@ -10,6 +10,10 @@ #include "ui/presentation_sources/runtime_mesh_status_source.h" #include "ui_presentation/mesh/mesh_status_model.h" +#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif + namespace ui::menu::dashboard { namespace diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp index 5c2832f9..c3219f02 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp @@ -10,6 +10,10 @@ #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" +#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif + namespace ui::menu::dashboard { namespace diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp index b243e0a8..ed77f8fd 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp @@ -7,6 +7,10 @@ #include "ui/assets/fonts/font_utils.h" +#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif + namespace ui::menu::dashboard { namespace diff --git a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp index 7646a21e..7a1e6d45 100644 --- a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp @@ -475,6 +475,7 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( } } row.outgoing = message.status != ::chat::MessageStatus::Incoming; + row.source_unverified = !row.outgoing && message.source_unverified; row.sender_node_id = row.outgoing ? 0 : message.from; copyString(row.text, message.text); copyTimeLabel(row.time_label, message.timestamp); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index a60e7136..23754026 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -1219,7 +1219,8 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row lv_obj_t* bubble = chat::ui::layout::create_bubble(item.container); item.bubble = bubble; - chat::ui::conversation::styles::apply_bubble(bubble, is_self); + chat::ui::conversation::styles::apply_bubble( + bubble, is_self, row.source_unverified); chat::ui::layout::set_bubble_max_width(bubble, max_bubble_w); char time_buf[24]; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp index 5fd209ca..b825426e 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp @@ -21,6 +21,7 @@ static lv_style_t s_row; static lv_style_t s_bubble_base; static lv_style_t s_bubble_self; static lv_style_t s_bubble_other; +static lv_style_t s_bubble_unverified; static lv_style_t s_bubble_text; static lv_style_t s_bubble_time; static lv_style_t s_bubble_status; @@ -124,6 +125,10 @@ void init_once() lv_style_set_bg_color(&s_bubble_other, kBubbleOther); lv_style_set_border_color(&s_bubble_other, lv_color_hex(0xE2C487)); + lv_style_init(&s_bubble_unverified); + lv_style_set_bg_color(&s_bubble_unverified, lv_color_hex(0xF4E1DE)); + lv_style_set_border_color(&s_bubble_unverified, lv_color_hex(0xC47D70)); + lv_style_init(&s_bubble_text); lv_style_set_text_color(&s_bubble_text, kTextColor); lv_style_set_text_align(&s_bubble_text, LV_TEXT_ALIGN_LEFT); @@ -177,11 +182,14 @@ void apply_message_row(lv_obj_t* row) lv_obj_add_style(row, &s_row, 0); } -void apply_bubble(lv_obj_t* bubble, bool is_self) +void apply_bubble(lv_obj_t* bubble, bool is_self, bool source_unverified) { init_once(); lv_obj_add_style(bubble, &s_bubble_base, LV_PART_MAIN); - lv_obj_add_style(bubble, is_self ? &s_bubble_self : &s_bubble_other, LV_PART_MAIN); + lv_style_t* message_style = + is_self ? &s_bubble_self + : (source_unverified ? &s_bubble_unverified : &s_bubble_other); + lv_obj_add_style(bubble, message_style, LV_PART_MAIN); } void apply_bubble_text(lv_obj_t* label) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index 64dbb900..8d7389b0 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -23,6 +23,7 @@ #include "ui/screens/chat/chat_team_workflow.h" #include "ui/ui_common.h" #include "ui/widgets/ime/ime_widget.h" +#include "ui/widgets/reticulum_ping_overlay.h" #include "ui_chat_runtime/chat_delivery_action_port_adapter.h" #include "ui_lvgl_ux_packs/common/key_verification_modal_renderer.h" #include "ui_lvgl_ux_packs/common/team_position_picker_renderer.h" @@ -1199,11 +1200,17 @@ void UiController::handlePingDestination(const chat::ConversationId& conv) return; } + const std::string display_name = base_conversation_name(conv); + ::ui::widgets::reticulum_ping::show_loading( + conv.reticulum_identity.destination_hash, + display_name.c_str()); const chat::MeshActionResult result = service_.pingReticulumDestination(conv.reticulum_identity); - ::ui::feedback::show_notice(result.ok ? "Ping sent" - : reticulum_ping_failure_message(result), - result.ok ? 1400 : 2200); + if (!result.ok) + { + ::ui::widgets::reticulum_ping::show_send_failure( + reticulum_ping_failure_message(result)); + } } void UiController::handleDeleteConversation(const chat::ConversationId& conv) diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp index 8f7eaa5b..510ba79c 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp @@ -303,8 +303,16 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row lv_label_set_text(item.text_label, display_text.c_str()); lv_obj_set_style_text_color(item.text_label, ::ui::theme::text(), 0); lv_obj_set_style_bg_color(item.text_label, - is_self ? ::ui::theme::surface_alt() : ::ui::theme::surface(), + is_self ? ::ui::theme::surface_alt() + : (row.source_unverified + ? lv_color_hex(0xF4E1DE) + : ::ui::theme::surface()), 0); + if (!is_self && row.source_unverified) + { + lv_obj_set_style_border_width(item.text_label, 1, 0); + lv_obj_set_style_border_color(item.text_label, lv_color_hex(0xC47D70), 0); + } lv_obj_set_style_bg_opa(item.text_label, LV_OPA_COVER, 0); lv_obj_set_style_pad_all(item.text_label, 6, 0); lv_obj_set_style_radius(item.text_label, 6, 0); diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index b9485892..54b27014 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -44,6 +44,7 @@ #include "ui/ui_common.h" #include "ui/widgets/busy_overlay.h" #include "ui/widgets/ime/ime_widget.h" +#include "ui/widgets/reticulum_ping_overlay.h" #include "ui/widgets/top_bar.h" #include @@ -1844,21 +1845,21 @@ static void on_lxmf_address_apply(const char* text, void* /*user_data*/) char generated_nickname[13] = {}; make_lxmf_short_name(identity, node_id, short_name, sizeof(short_name)); make_lxmf_contact_nickname(identity, generated_nickname, sizeof(generated_nickname)); - std::snprintf(nickname, sizeof(nickname), "%s", generated_nickname); + lv_strlcpy(nickname, generated_nickname, sizeof(nickname)); const char* address_display_name = has_full_address && address_record.display_name[0] != '\0' ? address_record.display_name : nullptr; if (address_display_name && std::strlen(address_display_name) <= 12U) { - std::snprintf(nickname, sizeof(nickname), "%s", address_display_name); + lv_strlcpy(nickname, address_display_name, sizeof(nickname)); } else if (existing_node) { const std::string existing_name = node_display_name_for_contacts(*existing_node); if (!existing_name.empty() && existing_name.size() <= 12U) { - std::snprintf(nickname, sizeof(nickname), "%s", existing_name.c_str()); + lv_strlcpy(nickname, existing_name.c_str(), sizeof(nickname)); } } @@ -3971,11 +3972,16 @@ static void start_reticulum_ping_for_selected_node() return; } + ::ui::widgets::reticulum_ping::show_loading( + node->reticulum_identity.destination_hash, + node->display_name.empty() ? node->long_name : node->display_name.c_str()); const chat::MeshActionResult result = g_contacts_state.chat_service->pingReticulumDestination(node->reticulum_identity); - ::ui::feedback::show_notice(result.ok ? "Ping sent" - : reticulum_ping_failure_message(result), - result.ok ? 1400 : 2200); + if (!result.ok) + { + ::ui::widgets::reticulum_ping::show_send_failure( + reticulum_ping_failure_message(result)); + } contacts_focus_to_list(); } diff --git a/modules/ui_shared/src/ui/screens/network/micron_markup_contract.cpp b/modules/ui_shared/src/ui/screens/network/micron_markup_contract.cpp index 3354dfb9..0fd5e4c7 100644 --- a/modules/ui_shared/src/ui/screens/network/micron_markup_contract.cpp +++ b/modules/ui_shared/src/ui/screens/network/micron_markup_contract.cpp @@ -48,6 +48,23 @@ bool is_hex_text(const char* text, std::size_t len) return true; } +uint8_t hex_nibble(char ch) +{ + if (ch >= '0' && ch <= '9') + { + return static_cast(ch - '0'); + } + if (ch >= 'A' && ch <= 'F') + { + return static_cast(ch - 'A' + 10); + } + if (ch >= 'a' && ch <= 'f') + { + return static_cast(ch - 'a' + 10); + } + return 0xFF; +} + std::size_t utf8_step_len(unsigned char lead) { if ((lead & 0x80U) == 0) @@ -347,6 +364,88 @@ void slug_from_text(const char* text, char* out, std::size_t out_len) out[out_pos] = '\0'; } +bool parse_color_text(const char* text, std::size_t len, std::uint32_t* out) +{ + if (!text || !out) + { + return false; + } + if (len == 3 && (text[0] == 'g' || text[0] == 'G') && + std::isdigit(static_cast(text[1])) && + std::isdigit(static_cast(text[2]))) + { + const std::uint32_t value = + static_cast((text[1] - '0') * 10 + (text[2] - '0')); + const std::uint32_t byte = value > 99U ? 255U : (value * 255U) / 99U; + *out = (byte << 16U) | (byte << 8U) | byte; + return true; + } + if (len == 3 && is_hex_text(text, len)) + { + const std::uint32_t r = static_cast(hex_nibble(text[0]) * 17U); + const std::uint32_t g = static_cast(hex_nibble(text[1]) * 17U); + const std::uint32_t b = static_cast(hex_nibble(text[2]) * 17U); + *out = (r << 16U) | (g << 8U) | b; + return true; + } + if (len == 6 && is_hex_text(text, len)) + { + std::uint32_t value = 0; + for (std::size_t i = 0; i < 6; ++i) + { + value = (value << 4U) | hex_nibble(text[i]); + } + *out = value; + return true; + } + return false; +} + +ColorToken parse_color_token(const char* line, std::size_t len, std::size_t index) +{ + ColorToken token{}; + if (!line || index >= len) + { + return token; + } + std::uint32_t rgb = 0; + if (index + 8U <= len && (line[index + 1U] == 'T' || line[index + 1U] == 't') && + parse_color_text(line + index + 2U, 6, &rgb)) + { + token.valid = true; + token.rgb = rgb; + token.consumed = 8; + return token; + } + if (index + 9U <= len && line[index + 4U] == '`' && + line[index + 5U] == line[index] && is_hex_text(line + index + 1U, 3) && + is_hex_text(line + index + 6U, 3)) + { + char expanded[6] = { + line[index + 6U], + line[index + 1U], + line[index + 7U], + line[index + 2U], + line[index + 8U], + line[index + 3U], + }; + if (parse_color_text(expanded, sizeof(expanded), &rgb)) + { + token.valid = true; + token.rgb = rgb; + token.consumed = 9; + return token; + } + } + if (index + 4U <= len && parse_color_text(line + index + 1U, 3, &rgb)) + { + token.valid = true; + token.rgb = rgb; + token.consumed = 4; + } + return token; +} + TableOpenOptions parse_table_opening_tag(const char* line) { TableOpenOptions options{}; @@ -607,6 +706,22 @@ LinkTargetKind classify_link_target(const char* target) return LinkTargetKind::Unknown; } +const char* link_target_kind_label(LinkTargetKind kind) +{ + switch (kind) + { + case LinkTargetKind::Page: + return "page"; + case LinkTargetKind::Anchor: + return "anchor"; + case LinkTargetKind::Resource: + return "resource"; + case LinkTargetKind::Unknown: + default: + return "unknown"; + } +} + bool extract_partial_url(const char* line, char* out, std::size_t out_len) { if (!out || out_len == 0) @@ -614,10 +729,22 @@ bool extract_partial_url(const char* line, char* out, std::size_t out_len) return false; } out[0] = '\0'; - if (!line || line[0] != '`' || line[1] != '{') + const PartialReference partial = parse_partial_reference(line); + if (!partial.valid) { return false; } + copy_range(out, out_len, partial.target, std::strlen(partial.target)); + return out[0] != '\0'; +} + +PartialReference parse_partial_reference(const char* line) +{ + PartialReference partial{}; + if (!line || line[0] != '`' || line[1] != '{') + { + return partial; + } const char* start = line + 2; const char* end = start; @@ -633,8 +760,36 @@ bool extract_partial_url(const char* line, char* out, std::size_t out_len) { ++start; } - copy_range(out, out_len, start, static_cast(end - start)); - return out[0] != '\0'; + copy_range(partial.target, + sizeof(partial.target), + start, + static_cast(end - start)); + if (partial.target[0] == '\0') + { + return partial; + } + partial.valid = true; + partial.target_kind = classify_link_target(partial.target); + + if (*end == '`') + { + const char* hint = end + 1; + while (ascii_space(*hint)) + { + ++hint; + } + while (std::isdigit(static_cast(*hint))) + { + partial.has_display_hint = true; + const unsigned value = + static_cast(partial.display_hint) * 10U + + static_cast(*hint - '0'); + partial.display_hint = + static_cast(value > 999U ? 999U : value); + ++hint; + } + } + return partial; } } // namespace ui::screens::network::micron diff --git a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp index 0f7e140f..22ca23a3 100644 --- a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #if defined(ESP_PLATFORM) @@ -82,9 +83,13 @@ constexpr std::size_t kHashTextLen = (rtdir::kReticulumHashSize * 2U) + 1U; constexpr std::size_t kMicronTextChunkBytes = 112; constexpr std::size_t kMicronTableMaxCells = micron::kMaxTableCells; constexpr std::size_t kMicronTableCellTextBytes = 96; +constexpr std::size_t kMicronUnsupportedTagSamples = 3; +constexpr std::size_t kMicronUnsupportedTagSampleBytes = 8; constexpr uint16_t kMicronMaxRenderedRows = 180; constexpr uint16_t kMicronMaxRenderedObjects = 520; +void copy_range(char* out, std::size_t out_len, const char* start, std::size_t len); + enum class DirectoryMode : uint8_t { Favourites, @@ -174,7 +179,13 @@ struct MicronRenderState uint16_t unsupported_block_count = 0; uint16_t unsupported_partial_count = 0; uint16_t unsupported_resource_links = 0; + uint16_t unsupported_unknown_links = 0; + std::array, + kMicronUnsupportedTagSamples> + unsupported_tag_samples{}; MicronAlign table_default_align = MicronAlign::Left; + uint8_t unsupported_tag_sample_count = 0; + uint8_t table_row_index = 0; bool literal = false; bool table_mode = false; bool table_first_row = false; @@ -232,6 +243,7 @@ struct NetworkPageState bool immersive = false; bool directory_collapsed = true; bool browser_collapsed = false; + bool viewport_scroll_locked = false; bool suppress_history = false; bool cached_page_body_valid = false; bool page_cache_load_requested = false; @@ -351,9 +363,40 @@ void network_page_free(void* ptr) #endif } +template +T* allocate_object_array(std::size_t count) +{ + void* storage = network_page_alloc(count * sizeof(T)); + if (!storage) + { + return nullptr; + } + + T* objects = static_cast(storage); + for (std::size_t i = 0; i < count; ++i) + { + new (&objects[i]) T{}; + } + return objects; +} + +template +void release_object_array(T* objects, std::size_t count) +{ + if (!objects) + { + return; + } + for (std::size_t i = 0; i < count; ++i) + { + objects[i].~T(); + } + network_page_free(objects); +} + void release_link_context_pool() { - network_page_free(g_state.link_contexts); + release_object_array(g_state.link_contexts, g_state.link_context_capacity); g_state.link_contexts = nullptr; g_state.link_context_capacity = 0; g_state.link_context_count = 0; @@ -361,7 +404,7 @@ void release_link_context_pool() void release_micron_anchor_registry() { - network_page_free(g_state.page_anchors); + release_object_array(g_state.page_anchors, g_state.page_anchor_capacity); g_state.page_anchors = nullptr; g_state.page_anchor_capacity = 0; g_state.page_anchor_count = 0; @@ -370,11 +413,9 @@ void release_micron_anchor_registry() void clear_micron_anchor_registry() { g_state.page_anchor_count = 0; - if (g_state.page_anchors && g_state.page_anchor_capacity > 0) + for (std::size_t i = 0; i < g_state.page_anchor_capacity; ++i) { - std::memset(g_state.page_anchors, - 0, - g_state.page_anchor_capacity * sizeof(MicronAnchor)); + g_state.page_anchors[i] = MicronAnchor{}; } } @@ -401,20 +442,19 @@ bool reserve_micron_anchors(std::size_t required) next_capacity = kMaxPageAnchors; } - void* storage = network_page_alloc(next_capacity * sizeof(MicronAnchor)); - if (!storage) + MicronAnchor* next_anchors = allocate_object_array(next_capacity); + if (!next_anchors) { return false; } - auto* next_anchors = static_cast(storage); - std::memset(next_anchors, 0, next_capacity * sizeof(MicronAnchor)); if (g_state.page_anchors && g_state.page_anchor_count > 0) { - std::memcpy(next_anchors, - g_state.page_anchors, - g_state.page_anchor_count * sizeof(MicronAnchor)); + for (std::size_t i = 0; i < g_state.page_anchor_count; ++i) + { + next_anchors[i] = g_state.page_anchors[i]; + } } - network_page_free(g_state.page_anchors); + release_object_array(g_state.page_anchors, g_state.page_anchor_capacity); g_state.page_anchors = next_anchors; g_state.page_anchor_capacity = next_capacity; return true; @@ -443,20 +483,19 @@ bool reserve_link_contexts(std::size_t required) next_capacity = kMaxPageLinks; } - void* storage = network_page_alloc(next_capacity * sizeof(LinkContext)); - if (!storage) + LinkContext* next_contexts = allocate_object_array(next_capacity); + if (!next_contexts) { return false; } - auto* next_contexts = static_cast(storage); - std::memset(next_contexts, 0, next_capacity * sizeof(LinkContext)); if (g_state.link_contexts && g_state.link_context_count > 0) { - std::memcpy(next_contexts, - g_state.link_contexts, - g_state.link_context_count * sizeof(LinkContext)); + for (std::size_t i = 0; i < g_state.link_context_count; ++i) + { + next_contexts[i] = g_state.link_contexts[i]; + } } - network_page_free(g_state.link_contexts); + release_object_array(g_state.link_contexts, g_state.link_context_capacity); g_state.link_contexts = next_contexts; g_state.link_context_capacity = next_capacity; return true; @@ -473,22 +512,35 @@ LinkContext* claim_link_context() return nullptr; } LinkContext* context = &g_state.link_contexts[g_state.link_context_count++]; - std::memset(context, 0, sizeof(*context)); + *context = LinkContext{}; return context; } +bool micron_anchor_name_exists(const char* name) +{ + if (!name || name[0] == '\0') + { + return false; + } + for (std::size_t i = 0; i < g_state.page_anchor_count; ++i) + { + if (std::strcmp(g_state.page_anchors[i].name, name) == 0) + { + return true; + } + } + return false; +} + void register_micron_anchor(const char* name, lv_obj_t* target, bool heading) { if (!name || name[0] == '\0' || !target) { return; } - for (std::size_t i = 0; i < g_state.page_anchor_count; ++i) + if (micron_anchor_name_exists(name)) { - if (std::strcmp(g_state.page_anchors[i].name, name) == 0) - { - return; - } + return; } if (g_state.page_anchor_count >= kMaxPageAnchors || !reserve_micron_anchors(g_state.page_anchor_count + 1U)) @@ -502,6 +554,42 @@ void register_micron_anchor(const char* name, lv_obj_t* target, bool heading) anchor.heading = heading; } +void register_micron_heading_anchor(const char* base_name, lv_obj_t* target) +{ + if (!base_name || base_name[0] == '\0' || !target) + { + return; + } + if (!micron_anchor_name_exists(base_name)) + { + register_micron_anchor(base_name, target, true); + return; + } + for (uint8_t suffix = 2; suffix <= kMaxPageAnchors; ++suffix) + { + char candidate[micron::kMaxAnchorNameBytes] = {}; + char suffix_text[8] = {}; + std::snprintf(suffix_text, sizeof(suffix_text), "-%u", static_cast(suffix)); + const std::size_t suffix_len = std::strlen(suffix_text); + const std::size_t max_base_len = + sizeof(candidate) > suffix_len + 1U ? sizeof(candidate) - suffix_len - 1U : 0; + const std::size_t base_len = std::strlen(base_name); + copy_range(candidate, + sizeof(candidate), + base_name, + base_len < max_base_len ? base_len : max_base_len); + std::snprintf(candidate + std::strlen(candidate), + sizeof(candidate) - std::strlen(candidate), + "%s", + suffix_text); + if (!micron_anchor_name_exists(candidate)) + { + register_micron_anchor(candidate, target, true); + return; + } + } +} + lv_obj_t* find_micron_anchor(const char* name) { if (!name || name[0] == '\0') @@ -1007,7 +1095,6 @@ bool cached_page_matches(const RemotePageAddress& page_address) std::strcmp(g_state.cached_page_path, page_address.path) == 0; } -void copy_range(char* out, std::size_t out_len, const char* start, std::size_t len); bool parse_remote_page_address(const char* address, RemotePageAddress& out); bool same_remote_page_identity(const char* lhs, const char* rhs) @@ -1274,6 +1361,7 @@ void page_load_timer_cb(lv_timer_t* timer); void rebuild_focus_group(lv_obj_t* preferred = nullptr); void focus_browser_viewport(); void focus_directory_panel(); +void focus_locked_viewport(); void apply_layout_state(); void open_node_list_page(); lv_coord_t resolve_directory_width(); @@ -1314,13 +1402,9 @@ void reset_state_after_destroy() std::vector().swap(g_state.addresses); release_link_context_pool(); release_micron_anchor_registry(); - std::memset(g_state.row_contexts.data(), - 0, - g_state.row_contexts.size() * sizeof(g_state.row_contexts[0])); - std::memset(g_state.history.data(), - 0, - g_state.history.size() * sizeof(g_state.history[0])); - std::memset(g_state.page_body.data(), 0, g_state.page_body.size()); + g_state.row_contexts.fill(DirectoryRowContext{}); + g_state.history.fill(HistoryEntry{}); + g_state.page_body.fill('\0'); g_state.announce_count = 0; g_state.address_count = 0; g_state.row_context_count = 0; @@ -1332,6 +1416,7 @@ void reset_state_after_destroy() g_state.immersive = false; g_state.directory_collapsed = true; g_state.browser_collapsed = false; + g_state.viewport_scroll_locked = false; g_state.suppress_history = false; g_state.cached_page_body_valid = false; g_state.page_cache_load_requested = false; @@ -1588,6 +1673,13 @@ void link_event_cb(lv_event_t* event) { return; } + if (g_state.viewport_scroll_locked) + { + focus_locked_viewport(); + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + return; + } auto* context = static_cast(lv_event_get_user_data(event)); if (!context) { @@ -1691,6 +1783,7 @@ void add_terminal_link(const char* target, const char* label, bool interactive = lv_obj_add_event_cb(btn, link_event_cb, LV_EVENT_CLICKED, context); lv_obj_add_event_cb(btn, link_event_cb, LV_EVENT_KEY, context); lv_obj_add_event_cb(btn, page_shortcut_event_cb, LV_EVENT_KEY, nullptr); + lv_obj_add_event_cb(btn, page_shortcut_event_cb, LV_EVENT_ROTARY, nullptr); add_to_group(btn); char text[192] = {}; @@ -1702,89 +1795,6 @@ void add_terminal_link(const char* target, const char* label, bool interactive = lv_obj_center(link_label); } -bool micron_color_from_text(const char* text, - std::size_t len, - uint32_t* out) -{ - if (!text || !out) - { - return false; - } - if (len == 3 && (text[0] == 'g' || text[0] == 'G') && - std::isdigit(static_cast(text[1])) && - std::isdigit(static_cast(text[2]))) - { - const uint32_t value = - static_cast((text[1] - '0') * 10 + (text[2] - '0')); - const uint32_t byte = value > 99U ? 255U : (value * 255U) / 99U; - *out = (byte << 16U) | (byte << 8U) | byte; - return true; - } - if (len == 3 && is_hex_text(text, len)) - { - const uint32_t r = static_cast(hex_nibble(text[0]) * 17U); - const uint32_t g = static_cast(hex_nibble(text[1]) * 17U); - const uint32_t b = static_cast(hex_nibble(text[2]) * 17U); - *out = (r << 16U) | (g << 8U) | b; - return true; - } - if (len == 6 && is_hex_text(text, len)) - { - uint32_t value = 0; - for (std::size_t i = 0; i < 6; ++i) - { - value = (value << 4U) | hex_nibble(text[i]); - } - *out = value; - return true; - } - return false; -} - -bool parse_micron_color_token(const char* line, - std::size_t len, - std::size_t index, - uint32_t* out, - std::size_t* consumed) -{ - if (!line || !out || !consumed || index >= len) - { - return false; - } - if (index + 8U <= len && line[index + 1U] == 'T' && - micron_color_from_text(line + index + 2U, 6, out)) - { - *consumed = 8; - return true; - } - if (index + 9U <= len && line[index + 4U] == '`' && - line[index + 5U] == line[index] && - is_hex_text(line + index + 1U, 3) && - is_hex_text(line + index + 6U, 3)) - { - char expanded[6] = { - line[index + 6U], - line[index + 1U], - line[index + 7U], - line[index + 2U], - line[index + 8U], - line[index + 3U], - }; - if (micron_color_from_text(expanded, sizeof(expanded), out)) - { - *consumed = 9; - return true; - } - } - if (index + 4U <= len && - micron_color_from_text(line + index + 1U, 3, out)) - { - *consumed = 4; - return true; - } - return false; -} - void copy_range(char* out, std::size_t out_len, const char* start, @@ -2080,6 +2090,42 @@ void reset_micron_style(MicronRenderState& state) state.align = state.default_align; } +void format_micron_tag_sample(char tag, char* out, std::size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + const auto value = static_cast(tag); + if (std::isprint(value)) + { + std::snprintf(out, out_len, "`%c", tag); + return; + } + std::snprintf(out, out_len, "0x%02X", static_cast(value)); +} + +void record_unsupported_micron_tag(MicronRenderState& state, char tag) +{ + ++state.unsupported_unknown_tags; + char sample[kMicronUnsupportedTagSampleBytes] = {}; + format_micron_tag_sample(tag, sample, sizeof(sample)); + for (uint8_t i = 0; i < state.unsupported_tag_sample_count; ++i) + { + if (std::strcmp(state.unsupported_tag_samples[i].data(), sample) == 0) + { + return; + } + } + if (state.unsupported_tag_sample_count < state.unsupported_tag_samples.size()) + { + copy_text(state.unsupported_tag_samples[state.unsupported_tag_sample_count].data(), + state.unsupported_tag_samples[state.unsupported_tag_sample_count].size(), + sample); + ++state.unsupported_tag_sample_count; + } +} + void normalize_micron_link_target(const char* target, char* out, std::size_t out_len) @@ -2128,9 +2174,18 @@ void emit_micron_link(lv_obj_t* row, const MicronStyle& style = state.style; const uint32_t default_bg = state.default_bg; - if (micron::classify_link_target(target) == micron::LinkTargetKind::Resource) + const micron::LinkTargetKind target_kind = micron::classify_link_target(target); + if (target_kind == micron::LinkTargetKind::Resource || + target_kind == micron::LinkTargetKind::Unknown) { - state.unsupported_resource_links++; + if (target_kind == micron::LinkTargetKind::Resource) + { + state.unsupported_resource_links++; + } + else + { + state.unsupported_unknown_links++; + } emit_micron_text(row, label && label[0] ? label : target, style, @@ -2143,7 +2198,9 @@ void emit_micron_link(lv_obj_t* row, note_style.bold = false; note_style.underline = false; emit_micron_text(row, - " [resource unsupported]", + target_kind == micron::LinkTargetKind::Resource + ? " [resource unsupported]" + : " [link unsupported]", note_style, default_bg, tiny_font(), @@ -2199,6 +2256,7 @@ void emit_micron_link(lv_obj_t* row, lv_obj_add_event_cb(btn, link_event_cb, LV_EVENT_CLICKED, context); lv_obj_add_event_cb(btn, link_event_cb, LV_EVENT_KEY, context); lv_obj_add_event_cb(btn, page_shortcut_event_cb, LV_EVENT_KEY, nullptr); + lv_obj_add_event_cb(btn, page_shortcut_event_cb, LV_EVENT_ROTARY, nullptr); lv_obj_t* text = lv_label_create(btn); const char* visible = label && label[0] ? label : target; @@ -2310,6 +2368,15 @@ void emit_micron_field(lv_obj_t* row, lv_obj_set_style_text_color(cb, lv_color_hex(style.fg), LV_PART_MAIN); lv_obj_set_style_text_font(cb, caption_font(), LV_PART_MAIN); lv_obj_set_style_text_opa(cb, LV_OPA_70, LV_PART_MAIN); + lv_obj_set_style_text_opa(cb, LV_OPA_60, LV_PART_MAIN | LV_STATE_DISABLED); + lv_obj_set_style_border_width(cb, 1, LV_PART_INDICATOR); + lv_obj_set_style_border_color(cb, lv_color_hex(kTerminalDim), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(cb, lv_color_hex(0x101010), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(cb, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(cb, + lv_color_hex(kTerminalDim), + LV_PART_INDICATOR | LV_STATE_CHECKED); + lv_obj_set_style_bg_opa(cb, LV_OPA_70, LV_PART_INDICATOR | LV_STATE_CHECKED); lv_obj_clear_flag(cb, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(cb, LV_OBJ_FLAG_CLICK_FOCUSABLE); lv_obj_add_state(cb, LV_STATE_DISABLED); @@ -2339,9 +2406,15 @@ void emit_micron_field(lv_obj_t* row, lv_obj_set_style_bg_opa(field, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(field, 1, LV_PART_MAIN); lv_obj_set_style_border_color(field, lv_color_hex(kTerminalDim), LV_PART_MAIN); + lv_obj_set_style_bg_color(field, lv_color_hex(0x151515), LV_PART_MAIN | LV_STATE_DISABLED); + lv_obj_set_style_bg_opa(field, LV_OPA_80, LV_PART_MAIN | LV_STATE_DISABLED); + lv_obj_set_style_border_color(field, + lv_color_hex(0x555555), + LV_PART_MAIN | LV_STATE_DISABLED); lv_obj_set_style_radius(field, 2, LV_PART_MAIN); lv_obj_set_style_text_color(field, lv_color_hex(style.fg), LV_PART_MAIN); lv_obj_set_style_text_opa(field, LV_OPA_70, LV_PART_MAIN); + lv_obj_set_style_text_opa(field, LV_OPA_60, LV_PART_MAIN | LV_STATE_DISABLED); lv_obj_set_style_text_font(field, caption_font(), LV_PART_MAIN); lv_obj_set_style_pad_all(field, 1, LV_PART_MAIN); lv_obj_clear_flag(field, LV_OBJ_FLAG_CLICKABLE); @@ -2402,18 +2475,19 @@ bool parse_micron_link(lv_obj_t* row, char label[96] = {}; char target[kAddressTextLen] = {}; char fields[96] = {}; - const char* first_tick = std::strchr(data, '`'); + const char* link_data = data[0] == '`' ? data + 1 : data; + const char* first_tick = std::strchr(link_data, '`'); if (!first_tick) { - copy_text(target, sizeof(target), data); - copy_text(label, sizeof(label), data); + copy_text(target, sizeof(target), link_data); + copy_text(label, sizeof(label), link_data); } else { copy_range(label, sizeof(label), - data, - static_cast(first_tick - data)); + link_data, + static_cast(first_tick - link_data)); const char* second_tick = std::strchr(first_tick + 1, '`'); if (second_tick) { @@ -2561,22 +2635,22 @@ void render_micron_inline(lv_obj_t* row, case 'F': case 'B': { - uint32_t color = 0; - std::size_t consumed = 0; - if (parse_micron_color_token(line, line_len, i, &color, &consumed)) + const micron::ColorToken color = micron::parse_color_token(line, line_len, i); + if (color.valid) { if (tag == 'F') { - state.style.fg = color; + state.style.fg = color.rgb; } else { - state.style.bg = color; + state.style.bg = color.rgb; } - i += consumed; + i += color.consumed; } else { + record_unsupported_micron_tag(state, tag); ++i; } break; @@ -2676,7 +2750,7 @@ void render_micron_inline(lv_obj_t* row, ++i; break; default: - state.unsupported_unknown_tags++; + record_unsupported_micron_tag(state, tag); ++i; break; } @@ -2771,12 +2845,21 @@ void render_micron_line(const char* line, MicronRenderState& state) { text = line + 1; } - lv_obj_t* row = create_micron_row(state); + lv_obj_t* row = create_micron_row(state, true, 0x101418); + if (row) + { + lv_obj_set_style_pad_left(row, 4, LV_PART_MAIN); + lv_obj_set_style_pad_top(row, 1, LV_PART_MAIN); + lv_obj_set_style_pad_bottom(row, 1, LV_PART_MAIN); + lv_obj_set_style_border_width(row, 2, LV_PART_MAIN); + lv_obj_set_style_border_side(row, LV_BORDER_SIDE_LEFT, LV_PART_MAIN); + lv_obj_set_style_border_color(row, lv_color_hex(kTerminalDim), LV_PART_MAIN); + } emit_micron_text(row, text, state.style, state.default_bg, - caption_font(), + tiny_font(), &state); return; } @@ -2809,7 +2892,7 @@ void render_micron_line(const char* line, MicronRenderState& state) lv_obj_t* row = create_micron_row(state, true, state.style.bg); char anchor[micron::kMaxAnchorNameBytes] = {}; micron::slug_from_text(text, anchor, sizeof(anchor)); - register_micron_anchor(anchor, row, true); + register_micron_heading_anchor(anchor, row); render_micron_inline(row, text, state, depth <= 1U ? body_font() : caption_font()); state.style = saved_style; return; @@ -2822,16 +2905,15 @@ void render_micron_line(const char* line, MicronRenderState& state) if (line[0] == '`' && line[1] == '{') { lv_obj_t* row = create_micron_row(state); - char partial_url[kAddressTextLen] = {}; - const bool has_partial_url = - micron::extract_partial_url(line, partial_url, sizeof(partial_url)); + const micron::PartialReference partial = micron::parse_partial_reference(line); char notice[192] = {}; - if (has_partial_url) + if (partial.valid) { std::snprintf(notice, sizeof(notice), - "[unsupported partial: %s]", - partial_url); + "[unsupported %s partial: %s]", + micron::link_target_kind_label(partial.target_kind), + partial.target); state.unsupported_partial_count++; } else @@ -2845,6 +2927,16 @@ void render_micron_line(const char* line, MicronRenderState& state) state.default_bg, caption_font(), &state); + if (partial.valid && partial.target_kind == micron::LinkTargetKind::Page) + { + emit_micron_text(row, + " ", + state.style, + state.default_bg, + caption_font(), + &state); + emit_micron_link(row, partial.target, "Open partial", state, false); + } return; } @@ -2867,6 +2959,7 @@ void begin_micron_table(MicronRenderState& state, { align = state.table_default_align; } + state.table_row_index = 0; state.table_first_row = true; state.table_mode = true; } @@ -2878,6 +2971,7 @@ void finish_micron_table(MicronRenderState& state) state.table_column_count = 0; state.table_default_align = MicronAlign::Left; state.table_max_width_chars = 0; + state.table_row_index = 0; state.table_first_row = false; state.table_mode = false; } @@ -2888,8 +2982,17 @@ void apply_micron_table_width(lv_obj_t* row, const MicronRenderState& state) { return; } - lv_obj_set_width(row, - static_cast(state.table_max_width_chars * 6U)); + const lv_coord_t requested = + static_cast(state.table_max_width_chars * 6U); + const lv_coord_t viewport_width = + g_state.viewport ? lv_obj_get_content_width(g_state.viewport) : 0; + lv_obj_set_style_max_width(row, LV_PCT(100), LV_PART_MAIN); + if (viewport_width > 0 && requested > viewport_width) + { + lv_obj_set_width(row, LV_PCT(100)); + return; + } + lv_obj_set_width(row, requested); } void update_micron_table_alignment( @@ -2928,7 +3031,13 @@ void render_micron_table_row(MicronRenderState& state, const char* row_text) } apply_micron_table_width(row, state); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(row, 1, LV_PART_MAIN); + lv_obj_set_style_pad_column(row, 2, LV_PART_MAIN); + lv_obj_set_style_pad_top(row, header_row ? 1 : 0, LV_PART_MAIN); + lv_obj_set_style_pad_bottom(row, header_row ? 1 : 0, LV_PART_MAIN); + const uint32_t row_bg = header_row ? 0x302814 + : (state.table_row_index % 2U == 0U) + ? state.style.bg + : 0x101010; for (std::size_t i = 0; i < cell_count; ++i) { char cell_text[kMicronTableCellTextBytes] = {}; @@ -2942,19 +3051,17 @@ void render_micron_table_row(MicronRenderState& state, const char* row_text) lv_obj_set_width(cell, 0); lv_obj_set_height(cell, LV_SIZE_CONTENT); lv_obj_set_flex_grow(cell, 1); - style_plain_container(cell, - header_row ? 0x242424 : state.style.bg, - LV_OPA_COVER); - lv_obj_set_style_border_width(cell, 1, LV_PART_MAIN); + style_plain_container(cell, row_bg, LV_OPA_COVER); + lv_obj_set_style_border_width(cell, header_row ? 2 : 1, LV_PART_MAIN); lv_obj_set_style_border_color( cell, - lv_color_hex(header_row ? kTerminalAmber : 0x333333), + lv_color_hex(header_row ? kTerminalAmber : 0x3A3A3A), LV_PART_MAIN); lv_obj_set_style_radius(cell, 1, LV_PART_MAIN); - lv_obj_set_style_pad_left(cell, 2, LV_PART_MAIN); - lv_obj_set_style_pad_right(cell, 2, LV_PART_MAIN); - lv_obj_set_style_pad_top(cell, 1, LV_PART_MAIN); - lv_obj_set_style_pad_bottom(cell, 1, LV_PART_MAIN); + lv_obj_set_style_pad_left(cell, header_row ? 3 : 2, LV_PART_MAIN); + lv_obj_set_style_pad_right(cell, header_row ? 3 : 2, LV_PART_MAIN); + lv_obj_set_style_pad_top(cell, header_row ? 2 : 1, LV_PART_MAIN); + lv_obj_set_style_pad_bottom(cell, header_row ? 2 : 1, LV_PART_MAIN); lv_obj_set_style_pad_row(cell, 0, LV_PART_MAIN); lv_obj_set_style_pad_column(cell, 0, LV_PART_MAIN); lv_obj_set_flex_flow(cell, LV_FLEX_FLOW_ROW_WRAP); @@ -2969,7 +3076,7 @@ void render_micron_table_row(MicronRenderState& state, const char* row_text) if (header_row) { cell_state.style.fg = kTerminalAmber; - cell_state.style.bg = 0x242424; + cell_state.style.bg = row_bg; } apply_micron_row_align(cell, cell_state.align); render_micron_inline(cell, cell_text, cell_state, tiny_font()); @@ -2979,6 +3086,9 @@ void render_micron_table_row(MicronRenderState& state, const char* row_text) state.unsupported_block_count = cell_state.unsupported_block_count; state.unsupported_partial_count = cell_state.unsupported_partial_count; state.unsupported_resource_links = cell_state.unsupported_resource_links; + state.unsupported_unknown_links = cell_state.unsupported_unknown_links; + state.unsupported_tag_samples = cell_state.unsupported_tag_samples; + state.unsupported_tag_sample_count = cell_state.unsupported_tag_sample_count; state.object_limit_reached = state.object_limit_reached || cell_state.object_limit_reached; state.link_limit_reached = @@ -2987,6 +3097,7 @@ void render_micron_table_row(MicronRenderState& state, const char* row_text) state.saw_submit_links = state.saw_submit_links || cell_state.saw_submit_links; } + ++state.table_row_index; return; } @@ -3041,16 +3152,18 @@ void parse_micron_header_colors(const char* body, if (line_end - line_start >= 6 && std::strncmp(line_start, "#!fg=", 5) == 0) { - (void)micron_color_from_text(line_start + 5, - static_cast(line_end - line_start - 5), - fg); + (void)micron::parse_color_text( + line_start + 5, + static_cast(line_end - line_start - 5), + fg); } else if (line_end - line_start >= 6 && std::strncmp(line_start, "#!bg=", 5) == 0) { - (void)micron_color_from_text(line_start + 5, - static_cast(line_end - line_start - 5), - bg); + (void)micron::parse_color_text( + line_start + 5, + static_cast(line_end - line_start - 5), + bg); } if (cursor < end) { @@ -3069,9 +3182,9 @@ void render_micron_notices(const MicronRenderState& state) const bool has_notice = state.link_limit_reached || state.saw_fields || state.saw_submit_links || state.unsupported_partial_count > 0 || state.unsupported_resource_links > 0 || - state.unsupported_inline_blocks > 0 || state.unsupported_block_count > 0 || - state.unsupported_unknown_tags > 0 || state.row_limit_reached || - state.object_limit_reached; + state.unsupported_unknown_links > 0 || state.unsupported_inline_blocks > 0 || + state.unsupported_block_count > 0 || state.unsupported_unknown_tags > 0 || + state.row_limit_reached || state.object_limit_reached; if (!has_notice) { return; @@ -3099,6 +3212,15 @@ void render_micron_notices(const MicronRenderState& state) static_cast(state.unsupported_resource_links)); add_micron_notice(line); } + if (state.unsupported_unknown_links > 0) + { + char line[64] = {}; + std::snprintf(line, + sizeof(line), + "unsupported links: %u", + static_cast(state.unsupported_unknown_links)); + add_micron_notice(line); + } if (state.unsupported_partial_count > 0) { char line[64] = {}; @@ -3121,11 +3243,27 @@ void render_micron_notices(const MicronRenderState& state) } if (state.unsupported_unknown_tags > 0) { - char line[64] = {}; + char line[96] = {}; std::snprintf(line, sizeof(line), "unsupported Micron tags: %u", static_cast(state.unsupported_unknown_tags)); + if (state.unsupported_tag_sample_count > 0) + { + std::size_t used = std::strlen(line); + std::snprintf(line + used, sizeof(line) - used, " ("); + used = std::strlen(line); + for (uint8_t i = 0; i < state.unsupported_tag_sample_count; ++i) + { + std::snprintf(line + used, + sizeof(line) - used, + "%s%s", + i == 0 ? "" : ", ", + state.unsupported_tag_samples[i].data()); + used = std::strlen(line); + } + std::snprintf(line + used, sizeof(line) - used, ")"); + } add_micron_notice(line); } if (state.row_limit_reached || state.object_limit_reached) @@ -4314,9 +4452,9 @@ void open_network_help_modal() rows[row_count++] = {"S", "/", "Search announces"}; rows[row_count++] = {"C", nullptr, "Nodes"}; rows[row_count++] = {"I", nullptr, "Immersive browser"}; - rows[row_count++] = {"Rotary", nullptr, "Move focus"}; - rows[row_count++] = {"Enter", nullptr, "Open or lock page scroll"}; - rows[row_count++] = {"Back", nullptr, "Unlock scroll or return"}; + rows[row_count++] = {"Rotary", nullptr, "Scroll page"}; + rows[row_count++] = {"Enter", nullptr, "Open link or lock page"}; + rows[row_count++] = {"Back", nullptr, "Unlock page or return"}; rows[row_count++] = {"R", nullptr, "Refresh network"}; rows[row_count++] = {"F", nullptr, "Favourites"}; rows[row_count++] = {"A", nullptr, "Nodes"}; @@ -4375,6 +4513,21 @@ bool viewport_scroll_delta_for_key(uint32_t key, lv_coord_t* delta) return false; } +bool target_in_viewport(lv_obj_t* target) +{ + return object_in_subtree(g_state.viewport, target); +} + +void focus_locked_viewport() +{ + if (!app_g || !g_state.viewport || !lv_obj_is_valid(g_state.viewport)) + { + return; + } + lv_group_focus_obj(g_state.viewport); + lv_group_set_editing(app_g, true); +} + bool scroll_viewport_by_key(lv_event_t* event) { if (!event || lv_event_get_code(event) != LV_EVENT_KEY || !g_state.viewport || @@ -4393,6 +4546,30 @@ bool scroll_viewport_by_key(lv_event_t* event) return true; } +bool route_key_to_viewport_scroll(lv_event_t* event) +{ + if (!event || lv_event_get_code(event) != LV_EVENT_KEY) + { + return false; + } + lv_coord_t delta = 0; + const uint32_t key = lv_event_get_key(event); + if (!viewport_scroll_delta_for_key(key, &delta)) + { + return false; + } + lv_obj_t* target = lv_event_get_target_obj(event); + if (!g_state.viewport_scroll_locked && !target_in_viewport(target)) + { + return false; + } + if (g_state.viewport_scroll_locked) + { + focus_locked_viewport(); + } + return scroll_viewport_by_key(event); +} + bool scroll_viewport_by_rotary(lv_event_t* event) { if (!event || lv_event_get_code(event) != LV_EVENT_ROTARY || !g_state.viewport || @@ -4414,14 +4591,46 @@ bool scroll_viewport_by_rotary(lv_event_t* event) return true; } +bool route_rotary_to_viewport_scroll(lv_event_t* event) +{ + if (!event || lv_event_get_code(event) != LV_EVENT_ROTARY) + { + return false; + } + lv_obj_t* target = lv_event_get_target_obj(event); + if (!g_state.viewport_scroll_locked && !target_in_viewport(target)) + { + return false; + } + if (g_state.viewport_scroll_locked) + { + focus_locked_viewport(); + } + return scroll_viewport_by_rotary(event); +} + +void unlock_viewport_scroll(lv_event_t* event = nullptr) +{ + g_state.viewport_scroll_locked = false; + if (app_g) + { + lv_group_set_editing(app_g, false); + } + if (event) + { + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + } +} + void lock_viewport_scroll(lv_event_t* event) { if (!app_g || !g_state.viewport || !lv_obj_is_valid(g_state.viewport)) { return; } - lv_group_focus_obj(g_state.viewport); - lv_group_set_editing(app_g, true); + g_state.viewport_scroll_locked = true; + focus_locked_viewport(); if (event) { lv_event_stop_bubbling(event); @@ -4461,8 +4670,9 @@ bool move_focus_by_pager_key(lv_event_t* event) bool viewport_is_editing() { - return app_g && lv_group_get_focused(app_g) == g_state.viewport && - lv_group_get_editing(app_g); + return g_state.viewport_scroll_locked || + (app_g && lv_group_get_focused(app_g) == g_state.viewport && + lv_group_get_editing(app_g)); } void viewport_event_cb(lv_event_t* event) @@ -4475,13 +4685,13 @@ void viewport_event_cb(lv_event_t* event) { if (app_g) { - lv_group_set_editing(app_g, false); + lv_group_set_editing(app_g, g_state.viewport_scroll_locked); } return; } if (lv_event_get_code(event) == LV_EVENT_DEFOCUSED) { - if (app_g) + if (app_g && !g_state.viewport_scroll_locked) { lv_group_set_editing(app_g, false); } @@ -4496,10 +4706,7 @@ void viewport_event_cb(lv_event_t* event) } if (lv_event_get_code(event) == LV_EVENT_ROTARY) { - if (viewport_is_editing()) - { - (void)scroll_viewport_by_rotary(event); - } + (void)route_rotary_to_viewport_scroll(event); return; } if (lv_event_get_code(event) != LV_EVENT_KEY || !app_g) @@ -4515,25 +4722,49 @@ void viewport_event_cb(lv_event_t* event) } if (viewport_is_editing() && (key == LV_KEY_BACKSPACE || key == LV_KEY_ESC)) { - lv_group_set_editing(app_g, false); - lv_event_stop_bubbling(event); - lv_event_stop_processing(event); + unlock_viewport_scroll(event); return; } - if (viewport_is_editing()) + if (viewport_is_editing() || target_in_viewport(lv_event_get_target_obj(event))) { - (void)scroll_viewport_by_key(event); + (void)route_key_to_viewport_scroll(event); } } void page_shortcut_event_cb(lv_event_t* event) { - if (!event || lv_event_get_code(event) != LV_EVENT_KEY) + if (!event) + { + return; + } + if (lv_event_get_code(event) == LV_EVENT_ROTARY) + { + (void)route_rotary_to_viewport_scroll(event); + return; + } + if (lv_event_get_code(event) != LV_EVENT_KEY) { return; } const uint32_t key = lv_event_get_key(event); lv_obj_t* target = lv_event_get_target_obj(event); + if (g_state.viewport_scroll_locked && (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE)) + { + unlock_viewport_scroll(event); + return; + } + if (route_key_to_viewport_scroll(event)) + { + return; + } + if (g_state.viewport_scroll_locked && + (key == LV_KEY_LEFT || key == LV_KEY_RIGHT || key == LV_KEY_ENTER)) + { + focus_locked_viewport(); + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + return; + } if (is_help_shortcut_key(key)) { open_network_help_modal(); diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 8c6b64e2..1309cedc 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -1310,6 +1310,10 @@ static void reset_mesh_settings() app_ctx.getConfig().reticulumConfig().reticulum_wifi_auto_connect; g_settings.rt_anonymous_peer = app_ctx.getConfig().reticulumConfig().reticulum_anonymous_peer; + g_settings.rt_call_wire = static_cast( + app_ctx.getConfig().reticulumConfig().reticulum_call_wire_profile); + g_settings.rt_location_requests = + app_ctx.getConfig().reticulumConfig().reticulum_allow_location_requests; g_settings.mt_mqtt_enabled = app_ctx.getConfig().meshtastic_mqtt_enabled; g_settings.mt_mqtt_uplink = app_ctx.getConfig().meshtastic_mqtt_uplink_enabled; g_settings.mt_mqtt_downlink = app_ctx.getConfig().meshtastic_mqtt_downlink_enabled; @@ -1614,6 +1618,10 @@ static void settings_load() g_settings.rt_wifi_gateway_enabled = reticulum_cfg.reticulum_wifi_gateway_enabled; g_settings.rt_wifi_auto_connect = reticulum_cfg.reticulum_wifi_auto_connect; g_settings.rt_anonymous_peer = reticulum_cfg.reticulum_anonymous_peer; + g_settings.rt_call_wire = + static_cast(reticulum_cfg.reticulum_call_wire_profile); + g_settings.rt_location_requests = + reticulum_cfg.reticulum_allow_location_requests; copy_bounded(g_settings.rt_wifi_gateway_host, sizeof(g_settings.rt_wifi_gateway_host), reticulum_cfg.reticulum_wifi_gateway_host); @@ -3241,6 +3249,20 @@ static void on_option_clicked(lv_event_t* e) app_ctx.applyMeshConfig(); rebuild_list = true; } + if (id == settings::ui::SettingId::RtCallWire) + { + app::IAppFacade& app_ctx = app::appFacade(); + const int clamped = payload->value == static_cast( + chat::ReticulumCallWireProfile::MeshChatCallAudio) + ? payload->value + : static_cast( + chat::ReticulumCallWireProfile::SidebandLxst); + g_settings.rt_call_wire = clamped; + app_ctx.getConfig().reticulumConfig().reticulum_call_wire_profile = + static_cast(clamped); + app_ctx.saveConfig(); + app_ctx.applyMeshConfig(); + } if (id == settings::ui::SettingId::MeshProtocol) { app::IAppFacade& app_ctx = app::appFacade(); @@ -4388,6 +4410,10 @@ static const settings::ui::SettingOption kReticulumBearerOptions[] = { {"LoRa", static_cast(chat::ReticulumInterfacePolicy::LoRaOnly)}, {"Wi-Fi", static_cast(chat::ReticulumInterfacePolicy::WifiGatewayOnly)}, }; +static const settings::ui::SettingOption kReticulumCallWireOptions[] = { + {"Sideband", static_cast(chat::ReticulumCallWireProfile::SidebandLxst)}, + {"MeshChat", static_cast(chat::ReticulumCallWireProfile::MeshChatCallAudio)}, +}; static const settings::ui::SettingOption kChatContactAlertOptions[] = { {"OFF", kChatContactAlertsNone}, {"Contacts Only", kChatContactAlertsContacts}, @@ -4577,6 +4603,8 @@ static settings::ui::SettingItem kMeshItems[] = { {"Gateway Port", settings::ui::SettingType::Text, nullptr, 0, nullptr, nullptr, g_settings.rt_wifi_gateway_port, sizeof(g_settings.rt_wifi_gateway_port), false, "rt_wifi_port"}, {"Auto Wi-Fi", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_wifi_auto_connect, nullptr, 0, false, "rt_wifi_auto"}, {"Anonymous Peer", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_anonymous_peer, nullptr, 0, false, "rt_anonymous_peer"}, + {"Call Protocol", settings::ui::SettingType::Enum, kReticulumCallWireOptions, sizeof(kReticulumCallWireOptions) / sizeof(kReticulumCallWireOptions[0]), &g_settings.rt_call_wire, nullptr, nullptr, 0, false, "rt_call_wire"}, + {"Location Requests", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_location_requests, nullptr, 0, false, "rt_location_requests"}, }; static settings::ui::SettingItem kRadioItems[] = { @@ -5121,6 +5149,15 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget) app_ctx.applyMeshConfig(); break; } + case settings::ui::SettingId::RtLocationRequests: + { + app::IAppFacade& app_ctx = app::appFacade(); + app_ctx.getConfig().reticulumConfig().reticulum_allow_location_requests = + *item.bool_value; + app_ctx.saveConfig(); + app_ctx.applyMeshConfig(); + break; + } case settings::ui::SettingId::NetOverrideDuty: { app::IAppFacade& app_ctx = app::appFacade(); diff --git a/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp b/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp index 7640287d..d6a2ebdb 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp @@ -70,6 +70,8 @@ constexpr SettingSpec kSpecs[] = { {SettingId::RtWifiPort, "rt_wifi_port", DynamicOptionKind::None}, {SettingId::RtWifiAuto, "rt_wifi_auto", DynamicOptionKind::None}, {SettingId::RtAnonymousPeer, "rt_anonymous_peer", DynamicOptionKind::None}, + {SettingId::RtCallWire, "rt_call_wire", DynamicOptionKind::None}, + {SettingId::RtLocationRequests, "rt_location_requests", DynamicOptionKind::None}, {SettingId::NetUsePreset, "net_use_preset", DynamicOptionKind::None}, {SettingId::NetPreset, "net_preset", DynamicOptionKind::None}, {SettingId::NetBw, "net_bw", DynamicOptionKind::None}, @@ -274,6 +276,8 @@ bool is_reticulum_mesh(SettingId id) case SettingId::RtWifiPort: case SettingId::RtWifiAuto: case SettingId::RtAnonymousPeer: + case SettingId::RtCallWire: + case SettingId::RtLocationRequests: return true; default: return false; diff --git a/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp index 70759bca..663daad0 100644 --- a/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp +++ b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp @@ -32,6 +32,8 @@ constexpr uint32_t kWarn = 0xB94A2C; constexpr uint32_t kOk = 0x3E7D3E; constexpr int kEncoderKeyRotateUp = 19; constexpr int kEncoderKeyRotateDown = 20; +constexpr uint8_t kVolumeStep = 5; +constexpr uint32_t kActivationGuardMs = 400; constexpr lv_coord_t kFallbackScreenW = 320; constexpr lv_coord_t kFallbackScreenH = 240; @@ -42,6 +44,7 @@ struct OverlayState lv_obj_t* title = nullptr; lv_obj_t* peer = nullptr; lv_obj_t* status = nullptr; + lv_obj_t* shortcut_hint = nullptr; lv_obj_t* answer_btn = nullptr; lv_obj_t* hangup_btn = nullptr; lv_obj_t* decline_btn = nullptr; @@ -49,6 +52,7 @@ struct OverlayState lv_group_t* previous_group = nullptr; AppScreen* owner_app = nullptr; bool needs_present = false; + uint32_t activation_armed_ms = 0; ::platform::ui::reticulum_call::State last_state = ::platform::ui::reticulum_call::State::Idle; }; @@ -244,6 +248,52 @@ bool handle_focus_key(lv_event_t* event) return false; } +void update_shortcut_hint() +{ + if (!s_overlay.shortcut_hint) + { + return; + } + char hint[48] = {}; + std::snprintf(hint, + sizeof(hint), + "+/- Volume %u%%", + static_cast( + ::platform::ui::reticulum_call::speaker_volume())); + apply_label(s_overlay.shortcut_hint, hint, kTextDim, caption_font()); +} + +bool handle_volume_key(lv_event_t* event) +{ + if (!event || lv_event_get_code(event) != LV_EVENT_KEY) + { + return false; + } + const uint32_t key = lv_event_get_key(event); + const bool louder = key == '+' || key == '='; + const bool quieter = key == '-' || key == '_'; + if (!louder && !quieter) + { + return false; + } + + const uint8_t current = ::platform::ui::reticulum_call::speaker_volume(); + const int delta = louder ? static_cast(kVolumeStep) : -static_cast(kVolumeStep); + int next = static_cast(current) + delta; + if (next < 0) + { + next = 0; + } + else if (next > 100) + { + next = 100; + } + ::platform::ui::reticulum_call::set_speaker_volume(static_cast(next)); + update_shortcut_hint(); + stop_input_event(event); + return true; +} + bool visible_action(lv_obj_t* obj, bool incoming) { if (!obj || !lv_obj_is_valid(obj) || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) @@ -311,6 +361,11 @@ bool activation_event(lv_event_t* event) { return false; } + if (static_cast(lv_tick_get() - s_overlay.activation_armed_ms) < 0) + { + stop_input_event(event); + return false; + } if (lv_event_get_code(event) == LV_EVENT_CLICKED) { return true; @@ -325,6 +380,10 @@ bool activation_event(lv_event_t* event) void answer_cb(lv_event_t* event) { + if (handle_volume_key(event)) + { + return; + } if (handle_focus_key(event)) { return; @@ -342,6 +401,10 @@ void answer_cb(lv_event_t* event) void hangup_cb(lv_event_t* event) { + if (handle_volume_key(event)) + { + return; + } if (handle_focus_key(event)) { return; @@ -356,6 +419,10 @@ void hangup_cb(lv_event_t* event) void decline_cb(lv_event_t* event) { + if (handle_volume_key(event)) + { + return; + } if (handle_focus_key(event)) { return; @@ -378,6 +445,10 @@ void root_key_cb(lv_event_t* event) { return; } + if (handle_volume_key(event)) + { + return; + } const uint32_t key = lv_event_get_key(event); const auto snapshot = ::platform::ui::reticulum_call::snapshot(); if ((key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) && @@ -517,8 +588,47 @@ void ensure_overlay() s_overlay.answer_btn = make_button(actions, "Answer", kOk, 0x2F662F, answer_cb); s_overlay.hangup_btn = make_button(actions, "Hang up", kWarn, 0x95351F, hangup_cb); s_overlay.decline_btn = make_button(actions, "Decline", kAmber, kAmberDark, decline_cb); + + s_overlay.shortcut_hint = lv_label_create(s_overlay.panel); + lv_obj_set_width(s_overlay.shortcut_hint, LV_PCT(100)); + lv_label_set_long_mode(s_overlay.shortcut_hint, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(s_overlay.shortcut_hint, + LV_TEXT_ALIGN_CENTER, + LV_PART_MAIN); lv_obj_move_foreground(s_overlay.root); s_overlay.needs_present = true; + s_overlay.activation_armed_ms = lv_tick_get() + kActivationGuardMs; +} + +bool hash_has_value(const uint8_t* hash) +{ + if (!hash) + { + return false; + } + for (std::size_t index = 0; index < ::platform::ui::reticulum_call::kHashSize; ++index) + { + if (hash[index] != 0U) + { + return true; + } + } + return false; +} + +void format_hash_prefix(const char* label, + const uint8_t* hash, + char* out, + std::size_t out_len) +{ + std::snprintf(out, + out_len, + "%s %.2X%.2X%.2X%.2X", + label, + static_cast(hash[0]), + static_cast(hash[1]), + static_cast(hash[2]), + static_cast(hash[3])); } void format_peer_name(const ::platform::ui::reticulum_call::Snapshot& snapshot, @@ -534,13 +644,22 @@ void format_peer_name(const ::platform::ui::reticulum_call::Snapshot& snapshot, std::snprintf(out, out_len, "%s", snapshot.peer_name); return; } - std::snprintf(out, - out_len, - "%.2X%.2X%.2X%.2X", - static_cast(snapshot.peer_destination_hash[0]), - static_cast(snapshot.peer_destination_hash[1]), - static_cast(snapshot.peer_destination_hash[2]), - static_cast(snapshot.peer_destination_hash[3])); + if (hash_has_value(snapshot.peer_destination_hash)) + { + format_hash_prefix("Dest", snapshot.peer_destination_hash, out, out_len); + return; + } + if (hash_has_value(snapshot.peer_identity_hash)) + { + format_hash_prefix("ID", snapshot.peer_identity_hash, out, out_len); + return; + } + if (hash_has_value(snapshot.link_id)) + { + format_hash_prefix("Link", snapshot.link_id, out, out_len); + return; + } + std::snprintf(out, out_len, "%s", "Unknown caller"); } void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot) @@ -600,6 +719,7 @@ void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot) std::snprintf(status, sizeof(status), "%s", "Wi-Fi call.audio"); } apply_label(s_overlay.status, status, kTextDim, caption_font()); + update_shortcut_hint(); if (incoming) { @@ -633,6 +753,7 @@ void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot) void tick() { + ::platform::ui::reticulum_call::service_ui_runtime(); const auto snapshot = ::platform::ui::reticulum_call::snapshot(); if (snapshot.realtime_phase == ::platform::ui::reticulum_call::RealtimePhase::Idle) diff --git a/modules/ui_shared/src/ui/widgets/reticulum_ping_overlay.cpp b/modules/ui_shared/src/ui/widgets/reticulum_ping_overlay.cpp new file mode 100644 index 00000000..861fc249 --- /dev/null +++ b/modules/ui_shared/src/ui/widgets/reticulum_ping_overlay.cpp @@ -0,0 +1,269 @@ +#include "ui/widgets/reticulum_ping_overlay.h" + +#include "lvgl.h" +#include "sys/clock.h" +#include "ui/widgets/foreground_operation_overlay.h" + +#include +#include +#include +#include + +namespace ui::widgets::reticulum_ping +{ +namespace +{ + +namespace foreground = ::ui::widgets::foreground_operation; + +constexpr std::uint32_t kFallbackTimeoutMs = 31000; +constexpr std::uint32_t kProgressHorizonMs = 30000; +constexpr std::uint32_t kProgressUpdateMs = 500; +constexpr std::uint32_t kResultHoldMs = 3200; +constexpr int kInitialProgress = 8; +constexpr int kWaitingProgressLimit = 92; + +enum class TimerPurpose : std::uint8_t +{ + None = 0, + AwaitResult, + DismissResult, +}; + +std::array s_destination_hash{}; +std::array s_loading_detail{}; +lv_timer_t* s_timer = nullptr; +std::uint32_t s_generation = 0; +std::uint32_t s_wait_started_ms = 0; +TimerPurpose s_timer_purpose = TimerPurpose::None; +int s_wait_progress = kInitialProgress; +bool s_active = false; + +void stop_timer() +{ + if (s_timer) + { + lv_timer_del(s_timer); + s_timer = nullptr; + } + s_timer_purpose = TimerPurpose::None; +} + +void publish_result(const char* title, const char* detail, int progress_percent) +{ + foreground::publish( + foreground::make_snapshot(foreground::Slot::ReticulumPing, + foreground::Policy::OverlayImmediate, + foreground::Priority::Blocking, + title, + detail, + progress_percent, + nullptr, + s_generation)); +} + +void publish_loading(int progress_percent, foreground::Policy policy) +{ + foreground::publish( + foreground::make_snapshot(foreground::Slot::ReticulumPing, + policy, + foreground::Priority::Blocking, + "Pinging", + s_loading_detail.data(), + progress_percent, + nullptr, + s_generation)); +} + +int waiting_progress(std::uint32_t elapsed_ms) +{ + const std::uint32_t bounded = std::min(elapsed_ms, kProgressHorizonMs); + const std::uint32_t span = static_cast( + kWaitingProgressLimit - kInitialProgress); + return kInitialProgress + static_cast((span * bounded) / kProgressHorizonMs); +} + +void timer_cb(lv_timer_t* timer) +{ + if (s_timer_purpose == TimerPurpose::AwaitResult) + { + if (!s_active) + { + s_timer = nullptr; + s_timer_purpose = TimerPurpose::None; + lv_timer_del(timer); + return; + } + + const std::uint32_t elapsed_ms = sys::millis_now() - s_wait_started_ms; + if (elapsed_ms >= kFallbackTimeoutMs) + { + s_timer = nullptr; + s_timer_purpose = TimerPurpose::None; + lv_timer_del(timer); + publish_result("No response", "Timeout after 30 s", 100); + s_timer_purpose = TimerPurpose::DismissResult; + s_timer = lv_timer_create(timer_cb, kResultHoldMs, nullptr); + if (s_timer) + { + lv_timer_set_repeat_count(s_timer, 1); + } + return; + } + + const int progress = waiting_progress(elapsed_ms); + if (progress > s_wait_progress) + { + s_wait_progress = progress; + publish_loading(s_wait_progress, foreground::Policy::Overlay); + } + return; + } + + const TimerPurpose purpose = s_timer_purpose; + s_timer = nullptr; + s_timer_purpose = TimerPurpose::None; + + if (purpose == TimerPurpose::DismissResult) + { + foreground::clear(foreground::Slot::ReticulumPing, s_generation); + s_destination_hash.fill(0); + s_loading_detail.fill(0); + s_active = false; + } +} + +void schedule_timer(TimerPurpose purpose, std::uint32_t delay_ms) +{ + stop_timer(); + s_timer_purpose = purpose; + s_timer = lv_timer_create(timer_cb, delay_ms, nullptr); + if (s_timer) + { + lv_timer_set_repeat_count(s_timer, 1); + } +} + +void schedule_result_dismiss() +{ + schedule_timer(TimerPurpose::DismissResult, kResultHoldMs); +} + +void schedule_wait_progress() +{ + stop_timer(); + s_timer_purpose = TimerPurpose::AwaitResult; + s_timer = lv_timer_create(timer_cb, kProgressUpdateMs, nullptr); + if (s_timer) + { + lv_timer_set_repeat_count(s_timer, -1); + } +} + +std::uint32_t next_generation() +{ + ++s_generation; + if (s_generation == 0) + { + ++s_generation; + } + return s_generation; +} + +} // namespace + +void show_loading( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize], + const char* display_name) +{ + if (!destination_hash) + { + return; + } + + stop_timer(); + next_generation(); + std::memcpy(s_destination_hash.data(), + destination_hash, + s_destination_hash.size()); + s_active = true; + + const char* detail = display_name && display_name[0] != '\0' + ? display_name + : "Waiting for response"; + std::snprintf(s_loading_detail.data(), s_loading_detail.size(), "%s", detail); + s_wait_started_ms = sys::millis_now(); + s_wait_progress = kInitialProgress; + publish_loading(s_wait_progress, foreground::Policy::OverlayImmediate); + schedule_wait_progress(); +} + +void show_send_failure(const char* detail) +{ + if (!s_active) + { + return; + } + publish_result("Ping failed", + detail && detail[0] != '\0' ? detail : "Unable to send", + 100); + schedule_result_dismiss(); +} + +void show_delivered( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize], + std::uint32_t latency_ms, + std::uint8_t hops) +{ + if (!active_for(destination_hash)) + { + return; + } + + char detail[64] = {}; + std::snprintf(detail, + sizeof(detail), + "Latency: %lu ms | Hops: %u", + static_cast(latency_ms), + static_cast(hops)); + publish_result("Reachable", detail, 100); + schedule_result_dismiss(); +} + +void show_timeout( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize], + std::uint32_t elapsed_ms) +{ + if (!active_for(destination_hash)) + { + return; + } + + char detail[48] = {}; + std::snprintf(detail, + sizeof(detail), + "Timeout after %lu s", + static_cast((elapsed_ms + 999U) / 1000U)); + publish_result("No response", detail, 100); + schedule_result_dismiss(); +} + +void dismiss() +{ + stop_timer(); + foreground::clear(foreground::Slot::ReticulumPing, s_generation); + s_destination_hash.fill(0); + s_loading_detail.fill(0); + s_active = false; +} + +bool active_for( + const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize]) +{ + return s_active && destination_hash && + std::memcmp(s_destination_hash.data(), + destination_hash, + s_destination_hash.size()) == 0; +} + +} // namespace ui::widgets::reticulum_ping diff --git a/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp b/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp index a2bb702b..5cdbb773 100644 --- a/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp +++ b/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp @@ -5,6 +5,10 @@ #include #include +#if !defined(LV_FONT_MONTSERRAT_10) || !LV_FONT_MONTSERRAT_10 +#define lv_font_montserrat_10 lv_font_montserrat_14 +#endif + namespace ui::widgets::route_elevation_profile { namespace diff --git a/modules/ui_shared/src/ui/widgets/route_image_strip.cpp b/modules/ui_shared/src/ui/widgets/route_image_strip.cpp index b5e9a14a..96794770 100644 --- a/modules/ui_shared/src/ui/widgets/route_image_strip.cpp +++ b/modules/ui_shared/src/ui/widgets/route_image_strip.cpp @@ -6,6 +6,13 @@ #include #include +#if !defined(LV_FONT_MONTSERRAT_10) || !LV_FONT_MONTSERRAT_10 +#define lv_font_montserrat_10 lv_font_montserrat_14 +#endif +#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif + namespace ui::widgets::route_image_strip { namespace diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index df619d6a..604654d7 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -598,6 +598,7 @@ int main() incoming.to = 0xFFFFFFFFUL; incoming.msg_id = 900; incoming.text = "broadcast hello"; + incoming.source_unverified = true; incoming.rx_meta.origin = ::chat::RxOrigin::LoRa; contacts.updateNodeInfo(0x648144D4, "44D4", @@ -622,6 +623,7 @@ int main() assert(!snapshot.messages[0].outgoing); assert(snapshot.messages[0].ingress_transport == ui::chat::MessageIngressTransport::LoRa); + assert(snapshot.messages[0].source_unverified); assert(snapshot.messages[0].sender_node_id == 0x648144D4); assert(std::strcmp(snapshot.messages[0].sender_label.c_str(), "Mother") == 0); diff --git a/modules/ui_shared/tests/test_network_micron_markup_contract.cpp b/modules/ui_shared/tests/test_network_micron_markup_contract.cpp index 1b3eab1e..2dc7cb40 100644 --- a/modules/ui_shared/tests/test_network_micron_markup_contract.cpp +++ b/modules/ui_shared/tests/test_network_micron_markup_contract.cpp @@ -1,6 +1,7 @@ #include "ui/screens/network/micron_markup_contract.h" #include +#include #include namespace micron = ui::screens::network::micron; @@ -14,6 +15,31 @@ int main() assert(std::strcmp(slug, "introduction-setup") == 0); micron::slug_from_text(">网络 页面", slug, sizeof(slug)); assert(std::strcmp(slug, "网络-页面") == 0); + micron::slug_from_text(">`[Visible label`/page/index.mu] `<12|name`value> `q", + slug, + sizeof(slug)); + assert(std::strcmp(slug, "visible-label") == 0); + + std::uint32_t color = 0; + assert(micron::parse_color_text("6cf", 3, &color)); + assert(color == 0x66CCFF); + assert(micron::parse_color_text("g50", 3, &color)); + assert(color == 0x808080); + assert(micron::parse_color_text("123456", 6, &color)); + assert(color == 0x123456); + micron::ColorToken short_color = micron::parse_color_token("F6cf", 4, 0); + assert(short_color.valid); + assert(short_color.rgb == 0x66CCFF); + assert(short_color.consumed == 4); + micron::ColorToken true_color = micron::parse_color_token("FT123456", 8, 0); + assert(true_color.valid); + assert(true_color.rgb == 0x123456); + assert(true_color.consumed == 8); + micron::ColorToken split_color = micron::parse_color_token("F123`F456", 9, 0); + assert(split_color.valid); + assert(split_color.rgb == 0x415263); + assert(split_color.consumed == 9); + assert(!micron::parse_color_token("Fzzzz", 5, 0).valid); micron::Span cells[micron::kMaxTableCells] = {}; const std::size_t count = @@ -30,6 +56,13 @@ int main() assert(centered.align == micron::Align::Center); assert(centered.has_max_width); assert(centered.max_width == 30); + const micron::TableOpenOptions right_clamped = + micron::parse_table_opening_tag("`tr12345"); + assert(right_clamped.valid); + assert(right_clamped.has_align); + assert(right_clamped.align == micron::Align::Right); + assert(right_clamped.has_max_width); + assert(right_clamped.max_width == 999); const micron::LinkFields anchor_only = micron::parse_link_fields("anchor=conclusion"); @@ -42,6 +75,10 @@ int main() micron::parse_link_fields("username|auth_token|action=view"); assert(submit_fields.has_fields); assert(submit_fields.submits_fields); + const micron::LinkFields anchor_and_submit = + micron::parse_link_fields("anchor=matrix|*"); + assert(anchor_and_submit.has_anchor); + assert(anchor_and_submit.submits_fields); char target[96] = {}; const bool appended = @@ -56,12 +93,28 @@ int main() assert(micron::classify_link_target( "0123456789abcdef0123456789abcdef:/file/manual.pdf") == micron::LinkTargetKind::Resource); + assert(micron::classify_link_target("https://example.invalid/manual") == + micron::LinkTargetKind::Resource); + assert(micron::classify_link_target("not-a-page") == micron::LinkTargetKind::Unknown); + assert(std::strcmp(micron::link_target_kind_label(micron::LinkTargetKind::Page), + "page") == 0); char partial[96] = {}; assert(micron::extract_partial_url("`{0123456789abcdef:/page/partial.mu`10}", partial, sizeof(partial))); assert(std::strcmp(partial, "0123456789abcdef:/page/partial.mu") == 0); + const micron::PartialReference page_partial = + micron::parse_partial_reference("`{:/page/live-status.mu`30}"); + assert(page_partial.valid); + assert(page_partial.target_kind == micron::LinkTargetKind::Page); + assert(page_partial.has_display_hint); + assert(page_partial.display_hint == 30); + assert(std::strcmp(page_partial.target, ":/page/live-status.mu") == 0); + const micron::PartialReference resource_partial = + micron::parse_partial_reference("`{:/file/photo.jpg`16}"); + assert(resource_partial.valid); + assert(resource_partial.target_kind == micron::LinkTargetKind::Resource); return 0; } diff --git a/modules/ui_shared/tests/test_network_micron_renderer_fixture.cpp b/modules/ui_shared/tests/test_network_micron_renderer_fixture.cpp new file mode 100644 index 00000000..bffd1058 --- /dev/null +++ b/modules/ui_shared/tests/test_network_micron_renderer_fixture.cpp @@ -0,0 +1,426 @@ +#include "ui/screens/network/micron_markup_contract.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace micron = ui::screens::network::micron; + +namespace +{ + +struct FixtureSummary +{ + unsigned header_colors = 0; + unsigned headings = 0; + unsigned anchors = 0; + unsigned links = 0; + unsigned page_links = 0; + unsigned anchor_links = 0; + unsigned resource_links = 0; + unsigned unknown_links = 0; + unsigned fields = 0; + unsigned submit_links = 0; + unsigned partials = 0; + unsigned page_partials = 0; + unsigned resource_partials = 0; + unsigned tables = 0; + unsigned table_rows = 0; + unsigned literal_lines = 0; + unsigned dividers = 0; + unsigned color_tokens = 0; + unsigned unknown_tags = 0; + bool saw_unicode_text = false; +}; + +std::string repo_root() +{ + const std::string file = __FILE__; + const std::string marker = + "modules/ui_shared/tests/test_network_micron_renderer_fixture.cpp"; + const std::size_t pos = file.find(marker); + assert(pos != std::string::npos); + return file.substr(0, pos); +} + +std::string read_text_file(const std::string& path) +{ + std::ifstream input(path, std::ios::binary); + assert(input.good()); + std::ostringstream out; + out << input.rdbuf(); + return out.str(); +} + +void trim_cr(std::string& line) +{ + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } +} + +void count_link_target(FixtureSummary& summary, micron::LinkTargetKind kind) +{ + ++summary.links; + switch (kind) + { + case micron::LinkTargetKind::Page: + ++summary.page_links; + break; + case micron::LinkTargetKind::Anchor: + ++summary.anchor_links; + break; + case micron::LinkTargetKind::Resource: + ++summary.resource_links; + break; + case micron::LinkTargetKind::Unknown: + default: + ++summary.unknown_links; + break; + } +} + +void scan_micron_link(const char* data, FixtureSummary& summary) +{ + char target[micron::kMaxLinkTargetBytes] = {}; + char fields[96] = {}; + const char* link_data = data && data[0] == '`' ? data + 1 : data; + const char* first_tick = std::strchr(link_data, '`'); + if (!first_tick) + { + std::snprintf(target, sizeof(target), "%s", link_data ? link_data : ""); + } + else + { + const char* second_tick = std::strchr(first_tick + 1, '`'); + if (second_tick) + { + const std::size_t target_len = + static_cast(second_tick - first_tick - 1); + std::snprintf(target, + sizeof(target), + "%.*s", + static_cast(target_len), + first_tick + 1); + std::snprintf(fields, sizeof(fields), "%s", second_tick + 1); + } + else + { + std::snprintf(target, sizeof(target), "%s", first_tick + 1); + } + } + + const micron::LinkFields link_fields = micron::parse_link_fields(fields); + if (link_fields.has_anchor) + { + char anchored[micron::kMaxLinkTargetBytes] = {}; + (void)micron::append_anchor_to_target( + target, link_fields.anchor, anchored, sizeof(anchored)); + if (anchored[0] != '\0') + { + std::snprintf(target, sizeof(target), "%s", anchored); + } + } + if (link_fields.submits_fields) + { + ++summary.submit_links; + } + count_link_target(summary, micron::classify_link_target(target)); +} + +void scan_inline(const std::string& text, FixtureSummary& summary) +{ + if (std::strstr(text.c_str(), "中文") != nullptr || + std::strstr(text.c_str(), "网络") != nullptr) + { + summary.saw_unicode_text = true; + } + bool escape = false; + for (std::size_t i = 0; i < text.size();) + { + const char ch = text[i]; + if (escape) + { + escape = false; + ++i; + continue; + } + if (ch == '\\') + { + escape = true; + ++i; + continue; + } + if (ch != '`') + { + ++i; + continue; + } + if (i + 1U >= text.size()) + { + break; + } + if (text[i + 1U] == '`') + { + i += 2; + continue; + } + + const std::size_t tag_index = i + 1U; + const char tag = text[tag_index]; + switch (tag) + { + case '!': + case '_': + case '*': + case 'f': + case 'b': + case 'c': + case 'l': + case 'r': + case 'a': + case '`': + i += 2; + break; + case 'F': + case 'B': + { + const micron::ColorToken color = + micron::parse_color_token(text.c_str(), text.size(), tag_index); + if (color.valid) + { + ++summary.color_tokens; + i = tag_index + color.consumed; + } + else + { + ++summary.unknown_tags; + i += 2; + } + break; + } + case ':': + { + std::size_t len = 0; + while (tag_index + 1U + len < text.size() && + micron::anchor_name_char(text[tag_index + 1U + len])) + { + ++len; + } + if (len > 0) + { + ++summary.anchors; + } + i = tag_index + 1U + len; + break; + } + case '<': + { + const char* spec_end = std::strchr(text.c_str() + tag_index + 1U, '`'); + const char* close = spec_end ? std::strchr(spec_end + 1U, '>') : nullptr; + if (close) + { + ++summary.fields; + i = static_cast(close - text.c_str()) + 1U; + } + else + { + ++summary.unknown_tags; + i += 2; + } + break; + } + case '[': + { + const char* close = std::strchr(text.c_str() + tag_index + 1U, ']'); + if (close) + { + char data[224] = {}; + const std::size_t len = + static_cast(close - (text.c_str() + tag_index + 1U)); + std::snprintf(data, sizeof(data), "%.*s", static_cast(len), + text.c_str() + tag_index + 1U); + scan_micron_link(data, summary); + i = static_cast(close - text.c_str()) + 1U; + } + else + { + ++summary.unknown_tags; + i += 2; + } + break; + } + case '{': + ++summary.unknown_tags; + while (i < text.size() && text[i] != '}') + { + ++i; + } + if (i < text.size()) + { + ++i; + } + break; + default: + ++summary.unknown_tags; + i += 2; + break; + } + } +} + +void scan_table_row(const std::string& line, FixtureSummary& summary) +{ + micron::Span cells[micron::kMaxTableCells] = {}; + const std::size_t count = + micron::parse_table_cells(line.c_str(), cells, micron::kMaxTableCells); + if (micron::table_separator_row(cells, count)) + { + return; + } + if (count == 0) + { + scan_inline(line, summary); + return; + } + ++summary.table_rows; + for (std::size_t i = 0; i < count; ++i) + { + scan_inline(std::string(cells[i].start, cells[i].len), summary); + } +} + +FixtureSummary summarize_fixture(const std::string& body) +{ + FixtureSummary summary{}; + bool literal = false; + bool table = false; + std::istringstream lines(body); + std::string line; + while (std::getline(lines, line)) + { + trim_cr(line); + if (line.rfind("#!fg=", 0) == 0 || line.rfind("#!bg=", 0) == 0) + { + ++summary.header_colors; + continue; + } + if (!literal && line == "`=") + { + literal = true; + continue; + } + if (literal) + { + if (line == "`=") + { + literal = false; + } + else + { + ++summary.literal_lines; + } + continue; + } + + const micron::TableOpenOptions table_options = + micron::parse_table_opening_tag(line.c_str()); + if (table_options.valid) + { + table = !table; + if (table) + { + ++summary.tables; + } + continue; + } + if (table) + { + scan_table_row(line, summary); + continue; + } + if (line.empty() || line[0] == '#') + { + continue; + } + if (line[0] == '>') + { + ++summary.headings; + const char* title = line.c_str(); + while (*title == '>') + { + ++title; + } + char slug[micron::kMaxAnchorNameBytes] = {}; + micron::slug_from_text(title, slug, sizeof(slug)); + if (slug[0] != '\0') + { + ++summary.anchors; + } + scan_inline(title, summary); + continue; + } + if (line.size() > 0 && line[0] == '-' && (line.size() == 1 || line.size() == 2)) + { + ++summary.dividers; + continue; + } + if (line.rfind("`{", 0) == 0) + { + const micron::PartialReference partial = + micron::parse_partial_reference(line.c_str()); + assert(partial.valid); + ++summary.partials; + if (partial.target_kind == micron::LinkTargetKind::Page) + { + ++summary.page_partials; + } + else if (partial.target_kind == micron::LinkTargetKind::Resource) + { + ++summary.resource_partials; + } + continue; + } + if (line[0] == '<') + { + scan_inline(line.substr(1), summary); + continue; + } + scan_inline(line, summary); + } + return summary; +} + +} // namespace + +int main() +{ + const std::string path = repo_root() + "docs/reticulum/pages/trail-mate/index.mu"; + const std::string body = read_text_file(path); + assert(body.size() > 3000U); + assert(body.size() < 4096U); + + const FixtureSummary summary = summarize_fixture(body); + assert(summary.header_colors == 2); + assert(summary.headings >= 8); + assert(summary.anchors >= 12); + assert(summary.links >= 10); + assert(summary.resource_links >= 1); + assert(summary.fields >= 5); + assert(summary.submit_links >= 1); + assert(summary.partials >= 1); + assert(summary.page_partials >= 1); + assert(summary.tables >= 3); + assert(summary.table_rows >= 17); + assert(summary.literal_lines >= 2); + assert(summary.dividers >= 2); + assert(summary.color_tokens >= 7); + assert(summary.unknown_tags >= 1); + assert(summary.saw_unicode_text); + return 0; +} diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h index bff619ab..f269b154 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -105,6 +105,7 @@ class LxmfAdapter : public IMeshAdapter static constexpr std::size_t kPendingPeerProjectionDepth = 24; static constexpr std::size_t kDeferredDiscoveryDepth = 8; static constexpr std::size_t kPeerDirectoryHotLoadRecords = 64; + static constexpr std::size_t kMaxPendingPingRequests = 4; static constexpr std::size_t kMaxPendingNomadPageRequests = 4; static constexpr std::size_t kNomadPagePathMaxLen = 64; static constexpr uint32_t kNomadPageRequestTtlMs = 90000; @@ -145,6 +146,24 @@ class LxmfAdapter : public IMeshAdapter bool request_sent = false; }; + struct PendingPingRequest + { + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint32_t created_ms = 0; + uint32_t last_path_request_ms = 0; + uint32_t last_send_attempt_ms = 0; + }; + + struct OutboundLxmfDispatch + { + bool ok = false; + bool result_event_deferred = false; + MessageId message_id = 0; + MeshOperationFailure failure = MeshOperationFailure::None; + uint8_t message_hash[reticulum::kFullHashSize] = {}; + const char* path = "none"; + }; + reticulum::interfaces::ReticulumInterfaceSet interfaces_; IMeshPeerDirectory* peer_directory_ = nullptr; reticulum::interfaces::RxPacket rx_packet_scratch_{}; @@ -185,10 +204,12 @@ class LxmfAdapter : public IMeshAdapter std::array pending_peer_projection_nodes_{}; std::size_t pending_peer_projection_count_ = 0; std::array peer_directory_load_entries_{}; + std::vector pending_ping_requests_; std::vector pending_nomad_page_requests_; uint8_t nomad_page_request_payload_scratch_[reticulum::kReticulumMtu] = {}; uint8_t nomad_page_wire_payload_scratch_[reticulum::kReticulumMtu] = {}; uint8_t nomad_page_packet_scratch_[reticulum::kReticulumMtu] = {}; + uint8_t call_wire_scratch_[reticulum::kReticulumMtu] = {}; uint32_t last_peer_projection_ms_ = 0; uint32_t next_app_packet_id_ = 1; bool announce_pending_ = true; @@ -258,6 +279,14 @@ class LxmfAdapter : public IMeshAdapter const uint8_t* packed_payload, size_t packed_payload_len, uint8_t* out_packet, size_t* inout_len, uint8_t out_message_hash[reticulum::kFullHashSize]); + bool dispatchLxmfPayload(PeerInfo& peer, + const uint8_t* packed_payload, + size_t packed_payload_len, + bool track_user_message, + OutboundLxmfDispatch* out_dispatch); + bool respondToSidebandTelemetryRequest( + PeerInfo& peer, + const SidebandTelemetryRequest& request); bool buildEncryptedPacketForPeer(const PeerInfo& peer, const uint8_t* plaintext, size_t plaintext_len, uint8_t* out_packet, size_t* inout_len); @@ -356,9 +385,15 @@ class LxmfAdapter : public IMeshAdapter reticulum::PacketType packet_type, reticulum::PacketContext context, const uint8_t* payload, size_t payload_len, - bool encrypt_payload); + bool encrypt_payload, + bool call_admission_control = false); bool sendNomadPageRequestPacket(LinkSession& session, PendingNomadPageRequest& request); + MeshActionResult sendReticulumPingToPeer(PeerInfo& peer, + uint32_t operation_started_ms); + MeshActionResult queuePendingReticulumPing( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]); + void pumpPendingPingRequests(); void pumpNomadPageRequests(); void completeNomadPageRequest(PendingNomadPageRequest& request, const std::vector& packed_response); @@ -382,13 +417,27 @@ class LxmfAdapter : public IMeshAdapter bool active, bool complete, platform::ui::reticulum_page::RequestProgress::FailureKind failure); - bool sendLinkHandshakeProof(LinkSession& session); + bool sendLinkHandshakeProof(LinkSession& session, + bool call_admission_control = false); bool sendLinkRtt(LinkSession& session); bool sendLinkKeepalive(LinkSession& session); bool sendLinkKeepaliveAck(LinkSession& session); bool sendLinkIdentify(LinkSession& session); bool sendLinkPacketProof(LinkSession& session, const uint8_t* raw_packet, size_t raw_len); + void updateCallRuntimePeer(LinkSession& session, + const PeerInfo* peer = nullptr); + bool beginIncomingCallRuntime(LinkSession& session, + const PeerInfo& peer); + bool sendLxstSignal(LinkSession& session, + uint16_t signal, + bool call_admission_control = false); + bool handleLxstPacket(LinkSession& session, + const uint8_t* payload, + size_t payload_len); + bool sendCallAudioPacket(LinkSession& session, + const uint8_t* payload, + size_t payload_len); void pumpReticulumAudioCall(); void closeLinkSession(LinkSession& session, LinkCloseReason reason = LinkCloseReason::LocalClose); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h index 7a3dbe77..3dc19326 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h @@ -1,6 +1,6 @@ /** * @file lxmf_delivery_runtime.h - * @brief Product ingress materialisation for verified LXMF deliveries. + * @brief Product ingress materialisation for authenticated and source-unknown LXMF deliveries. */ #pragma once @@ -33,6 +33,7 @@ struct LxmfDeliveryContext ReticulumPeerIdentity conversation_identity{}; bool destination_is_group = false; bool encrypted = true; + bool source_unverified = false; RxMeta rx_meta{}; }; @@ -40,6 +41,7 @@ struct LxmfMaterialisedText { MeshIncomingText incoming{}; std::string text; + DecodedTextPayload payload{}; }; struct LxmfMaterialisedAppData diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h index 89ba4839..76bebb03 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h @@ -5,6 +5,7 @@ #pragma once +#include "chat/domain/chat_types.h" #include "chat/infra/lxmf/lxmf_wire.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" @@ -16,6 +17,9 @@ namespace chat::lxmf::runtime { +constexpr std::size_t kAnnounceRandomBlobSize = 10; +constexpr std::size_t kPathRandomBlobHistory = 4; + struct PeerInfo { uint32_t node_id = 0; @@ -38,8 +42,12 @@ struct PathEntry uint8_t cached_packet_hash[reticulum::kFullHashSize] = {}; uint8_t cached_announce[reticulum::kReticulumMtu] = {}; size_t cached_announce_len = 0; + uint8_t announce_random_blobs[kPathRandomBlobHistory][kAnnounceRandomBlobSize] = {}; + uint8_t announce_random_blob_count = 0; uint8_t hops = 0; uint32_t last_seen_s = 0; + uint32_t updated_ms = 0; + uint64_t announce_timebase = 0; bool direct = false; }; @@ -65,6 +73,15 @@ struct PendingPathRequest bool resolved = false; }; +struct PendingPingReceipt +{ + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t peer_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; + uint32_t created_ms = 0; +}; + struct LinkRelayEntry { uint8_t link_id[reticulum::kTruncatedHashSize] = {}; @@ -186,8 +203,17 @@ struct LinkSession uint32_t last_keepalive_ms = 0; uint8_t expected_hops = 0; bool initiator = false; + bool local_identity_sent = false; bool remote_identity_known = false; bool validated = false; + ReticulumCallWireProfile call_wire_profile = + ReticulumCallWireProfile::SidebandLxst; + uint16_t lxst_profile = 0x30; + uint16_t lxst_local_status = 0x02; + uint16_t lxst_remote_status = 0x02; + uint32_t call_status_ms = 0; + bool call_runtime_started = false; + bool lxst_preferred_sent = false; LocalDestinationKind destination = LocalDestinationKind::Delivery; LinkState state = LinkState::Pending; LinkCloseReason close_reason = LinkCloseReason::None; @@ -231,6 +257,7 @@ struct TransportRuntime std::vector packet_filter; std::vector reverse_table; std::vector pending_path_requests; + std::vector pending_ping_receipts; std::vector link_relays; }; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h index ba892c79..091175f3 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h @@ -24,14 +24,42 @@ struct TransportRuntimeLimits uint32_t pending_path_request_ttl_ms = 0; uint32_t reverse_entry_ttl_ms = 0; uint32_t link_relay_ttl_ms = 0; + std::size_t max_pending_ping_receipts = 0; + uint32_t path_ttl_ms = 0; + uint32_t pending_ping_receipt_ttl_ms = 0; }; +enum class PathAnnounceDecision : uint8_t +{ + AcceptNew = 0, + AcceptNewer = 1, + AcceptExpired = 2, + RejectReplay = 3, + RejectStale = 4 +}; + +bool pathAnnounceAccepted(PathAnnounceDecision decision); +bool pathExpired(const PathEntry& path, uint32_t now_ms, uint32_t path_ttl_ms); +PathAnnounceDecision evaluatePathAnnounce( + const PathEntry* existing, + uint8_t hops, + const uint8_t random_blob[kAnnounceRandomBlobSize], + uint32_t now_ms, + uint32_t path_ttl_ms); +void applyPathAnnounce(PathEntry& path, + uint8_t hops, + const uint8_t random_blob[kAnnounceRandomBlobSize], + uint32_t now_ms, + uint32_t last_seen_s); + bool isDuplicatePacket(const TransportRuntime& transport, const uint8_t packet_hash[reticulum::kFullHashSize]); void rememberPacket(TransportRuntime& transport, const uint8_t packet_hash[reticulum::kFullHashSize], uint32_t now_ms, std::size_t max_packet_filter); +void forgetPacket(TransportRuntime& transport, + const uint8_t packet_hash[reticulum::kFullHashSize]); void rememberReversePath(TransportRuntime& transport, const uint8_t proof_hash[reticulum::kTruncatedHashSize], @@ -54,6 +82,20 @@ void notePendingPathRequest(TransportRuntime& transport, void resolvePendingPathRequest(TransportRuntime& transport, const uint8_t destination_hash[reticulum::kTruncatedHashSize]); +void notePendingPingReceipt( + TransportRuntime& transport, + const uint8_t packet_hash[reticulum::kFullHashSize], + const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t peer_sig_pub[LxmfIdentity::kSigPubKeySize], + uint32_t now_ms, + std::size_t max_pending_ping_receipts); +PendingPingReceipt* findPendingPingReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]); +void removePendingPingReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]); + PathEntry& upsertPath(TransportRuntime& transport, const uint8_t destination_hash[reticulum::kTruncatedHashSize], std::size_t max_paths); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h index 5278666f..ad98085f 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h @@ -5,11 +5,10 @@ #pragma once -#include - #include "chat/domain/chat_types.h" #include "chat/infra/mesh_adapter_router_core.h" #include "chat/ports/i_mesh_adapter.h" +#include "freertos/FreeRTOS.h" #include "freertos/semphr.h" namespace chat diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h index 08e4967b..dc27d6be 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h @@ -16,11 +16,20 @@ #if __has_include() #include -#define TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE 1 +#define TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE 1 #else -#define TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE 0 +#define TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE 0 #endif +#if defined(ESP_PLATFORM) && !defined(ARDUINO) +#define TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE 1 +#else +#define TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE 0 +#endif + +#define TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE \ + (TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE || TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE) + namespace chat::reticulum::interfaces { @@ -88,7 +97,8 @@ class WifiGatewayReticulumInterface bool isConfigured() const; bool sendPacket(const uint8_t* data, size_t len, - const uint8_t* call_link_id = nullptr); + const uint8_t* call_link_id = nullptr, + bool call_admission_control = false); bool pollPacket(RxPacket* out); const char* host() const { return host_; } uint16_t port() const { return port_; } @@ -117,6 +127,7 @@ class WifiGatewayReticulumInterface char host_[kReticulumGatewayHostMaxLen + 1] = {}; uint16_t port_ = 4242; bool socket_online_ = false; + bool socket_open_pending_ = false; uint32_t last_reconnect_ms_ = 0; uint32_t last_wifi_connect_ms_ = 0; uint32_t last_socket_read_ms_ = 0; @@ -131,20 +142,22 @@ class WifiGatewayReticulumInterface uint32_t rx_stats_read_skips_ = 0; uint8_t hdlc_frame_[reticulum::kReticulumMtu] = {}; uint8_t tx_frame_[(reticulum::kReticulumMtu * 2U) + 2U] = {}; + uint8_t socket_rx_scratch_[256] = {}; QueuedPacket poll_scratch_{}; QueuedPacket enqueue_scratch_{}; sys::RingBuffer rx_priority_queue_; sys::RingBuffer rx_queue_; -#if TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE WiFiClient client_; #endif void stop(); bool connected() const; -#if TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE bool resolveHost(IPAddress* out); #endif + void syncSocketState(); bool ensureSocket(); void readAvailable(); void feedHdlcByte(uint8_t byte); @@ -166,7 +179,8 @@ class ReticulumInterfaceSet bool sendPacket(const uint8_t* data, size_t len); bool sendPacketWifiOnly(const uint8_t* data, size_t len, - const uint8_t* call_link_id = nullptr); + const uint8_t* call_link_id = nullptr, + bool call_admission_control = false); bool pollIncomingPacket(RxPacket* out); bool pollLegacyIncomingData(MeshIncomingData* out); void handleRawPacket(const uint8_t* data, size_t size); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h index 58fd1acd..864645d9 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h @@ -151,6 +151,8 @@ class SdStore final : public IChatStore bool ensureFs() const; bool ensureDir() const; + bool recoverIndex() const; + bool reconcileIndexUnread(std::vector& entries) const; bool readIndex(std::vector& entries) const; bool writeIndex(const std::vector& entries) const; bool ensureIndex(std::vector& entries); @@ -160,7 +162,7 @@ class SdStore final : public IChatStore bool findIndexEntry(const ConversationId& conv, const std::vector& entries, size_t* out_idx) const; - void updateIndexForMessage(const ChatMessage& msg); + void updateIndexForMessage(const ChatMessage& msg, uint16_t unread); void rebuildIndex(); bool loadFileHeader(::platform::esp::arduino_common::storage::SdRuntimeFile& file, FileHeader& header) const; @@ -178,6 +180,8 @@ class SdStore final : public IChatStore bool openConversationForUpdate(const ConversationId& conv, ::platform::esp::arduino_common::storage::SdRuntimeFile& file, FileHeader& header) const; + bool readConversationUnread(const ConversationId& conv, uint16_t* unread) const; + bool writeConversationUnread(const ConversationId& conv, uint16_t unread) const; void buildConversationPath(const ConversationId& conv, char* out, size_t out_len) const; const char* channelName(ChannelId channel) const; static ChatMessage messageFromRecord(const Record& rec); @@ -190,6 +194,7 @@ class SdStore final : public IChatStore static bool hasLogSuffix(const char* name); bool ready_ = false; + bool unread_reconcile_pending_ = false; }; } // namespace chat diff --git a/platform/esp/arduino_common/include/screen_sleep.h b/platform/esp/arduino_common/include/screen_sleep.h index a516e6d6..60f03aac 100644 --- a/platform/esp/arduino_common/include/screen_sleep.h +++ b/platform/esp/arduino_common/include/screen_sleep.h @@ -49,6 +49,9 @@ void wakeScreenSaver(); /** Enter the UI from the transient screen saver state. */ void enterFromScreenSaver(); +/** Wake for a modal without navigating the background UI. */ +void wakeScreenForModal(); + /** Refresh the user-activity timestamp and wake the screen if needed. */ void updateUserActivity(); diff --git a/platform/esp/arduino_common/include/sys/event_bus.h b/platform/esp/arduino_common/include/sys/event_bus.h index 464a2785..d275d252 100644 --- a/platform/esp/arduino_common/include/sys/event_bus.h +++ b/platform/esp/arduino_common/include/sys/event_bus.h @@ -47,7 +47,8 @@ enum class EventType TeamPairing, // Team pairing state update TeamError, // Team protocol error InputEvent, // Input event (keyboard/rotary) - SystemTick // System tick (for periodic tasks) + SystemTick, // System tick (for periodic tasks) + ReticulumPingResult // Reticulum destination proof or timeout }; /** @@ -104,6 +105,38 @@ struct ChatSendResultEvent : public Event : Event(EventType::ChatSendResult), msg_id(id), success(ok) {} }; +enum class ReticulumPingResult : uint8_t +{ + Delivered = 0, + Timeout, +}; + +struct ReticulumPingResultEvent : public Event +{ + uint8_t destination_hash[chat::kReticulumPeerHashSize] = {}; + ReticulumPingResult result = ReticulumPingResult::Timeout; + uint32_t elapsed_ms = 0; + uint8_t hops = 0; + + ReticulumPingResultEvent( + const uint8_t hash[chat::kReticulumPeerHashSize], + ReticulumPingResult result_value, + uint32_t elapsed_ms_value, + uint8_t hops_value = 0) + : Event(EventType::ReticulumPingResult), + result(result_value), + elapsed_ms(elapsed_ms_value), + hops(hops_value) + { + if (hash) + { + std::memcpy(destination_hash, + hash, + sizeof(destination_hash)); + } + } +}; + /** * @brief Chat unread changed event */ diff --git a/platform/esp/arduino_common/library.json b/platform/esp/arduino_common/library.json index 2ae180a9..48077496 100644 --- a/platform/esp/arduino_common/library.json +++ b/platform/esp/arduino_common/library.json @@ -36,6 +36,9 @@ ] }, "dependencies": [ + { + "name": "esp_common" + }, { "name": "esp_boards" }, diff --git a/platform/esp/arduino_common/src/app_config_store.cpp b/platform/esp/arduino_common/src/app_config_store.cpp index 85406f9d..d8412f9e 100644 --- a/platform/esp/arduino_common/src/app_config_store.cpp +++ b/platform/esp/arduino_common/src/app_config_store.cpp @@ -67,6 +67,8 @@ constexpr const char* kChatKeyRtWifiHost = "rt_wifi_host"; constexpr const char* kChatKeyRtWifiPort = "rt_wifi_port"; constexpr const char* kChatKeyRtIfacePolicy = "rt_if_policy"; constexpr const char* kChatKeyRtAnonPeer = "rt_anon_peer"; +constexpr const char* kChatKeyRtCallWire = "rt_call_wire"; +constexpr const char* kChatKeyRtLocationRequests = "rt_loc_req"; constexpr const char* kGpsKeyInitBaud = "init_baud"; constexpr const char* kGpsKeyInitProbeMs = "init_probe_ms"; constexpr const char* kGpsKeyInitProfile = "init_profile"; @@ -880,7 +882,7 @@ void log_config_summary(const char* phase, const AppConfig& config) bool_label(config.aprs.self_enable), config.aprs.self_callsign, static_cast(config.aprs.node_map_len)); - Serial.printf("[AppCfg][%s][reticulum_if] lora=%s wifi=%s auto_wifi=%s anonymous=%s host=%s port=%u policy=%u\n", + Serial.printf("[AppCfg][%s][reticulum_if] lora=%s wifi=%s auto_wifi=%s anonymous=%s host=%s port=%u policy=%u call_wire=%u location_requests=%s\n", safe_label(phase), bool_label(config.rnode_config.reticulum_lora_enabled), bool_label(config.rnode_config.reticulum_wifi_gateway_enabled), @@ -891,7 +893,10 @@ void log_config_summary(const char* phase, const AppConfig& config) : "", static_cast(config.rnode_config.reticulum_wifi_gateway_port), static_cast(static_cast( - config.rnode_config.reticulum_interface_policy))); + config.rnode_config.reticulum_interface_policy)), + static_cast(static_cast( + config.rnode_config.reticulum_call_wire_profile)), + bool_label(config.rnode_config.reticulum_allow_location_requests)); } } // namespace @@ -1168,6 +1173,18 @@ bool loadAppConfigFromPreferences(AppConfig& config, rnode_config.reticulum_interface_policy = static_cast(reticulum_interface_policy); } + const uint8_t reticulum_call_wire = + get_uchar(kChatKeyRtCallWire, + static_cast(rnode_config.reticulum_call_wire_profile)); + if (reticulum_call_wire <= + static_cast(chat::ReticulumCallWireProfile::MeshChatCallAudio)) + { + rnode_config.reticulum_call_wire_profile = + static_cast(reticulum_call_wire); + } + rnode_config.reticulum_allow_location_requests = + get_bool(kChatKeyRtLocationRequests, + rnode_config.reticulum_allow_location_requests); const uint8_t mesh_protocol_raw = get_uchar("mesh_protocol", 0xFF); if (chat::infra::isValidMeshProtocolValue(mesh_protocol_raw)) @@ -1177,10 +1194,10 @@ bool loadAppConfigFromPreferences(AppConfig& config, else { const int legacy_protocol = get_int("mesh_protocol", - static_cast(chat::MeshProtocol::Meshtastic)); + static_cast(mesh_protocol)); mesh_protocol = chat::infra::meshProtocolFromRaw( static_cast(legacy_protocol), - chat::MeshProtocol::Meshtastic); + mesh_protocol); } // Load device name @@ -1611,6 +1628,10 @@ bool saveAppConfigToPreferences(const AppConfig& config, : 4242); put_uchar(kChatKeyRtIfacePolicy, static_cast(rnode_config.reticulum_interface_policy)); + put_uchar(kChatKeyRtCallWire, + static_cast(rnode_config.reticulum_call_wire_profile)); + put_bool(kChatKeyRtLocationRequests, + rnode_config.reticulum_allow_location_requests); const char* legacy_reticulum_group_suffixes[] = {"en", "name", "dest"}; for (size_t index = 0; index < chat::kReticulumGroupDestinationMaxCount; ++index) { diff --git a/platform/esp/arduino_common/src/app_event_runtime_support.cpp b/platform/esp/arduino_common/src/app_event_runtime_support.cpp index 2c449f27..f8d041fa 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -16,6 +16,7 @@ #include "ui/localization.h" #include "ui/runtime/ui_feedback.h" #include "ui/screens/team/team_page_shell.h" +#include "ui/widgets/reticulum_ping_overlay.h" #include "ui/widgets/top_bar_power_presenter.h" #include "ui_chat_runtime/chat_delivery_feedback_controller.h" @@ -196,6 +197,26 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) switch (event->type) { + case sys::EventType::ReticulumPingResult: + { + const auto* ping_event = + static_cast(event); + if (ping_event->result == sys::ReticulumPingResult::Delivered) + { + ::ui::widgets::reticulum_ping::show_delivered( + ping_event->destination_hash, + ping_event->elapsed_ms, + ping_event->hops); + } + else + { + ::ui::widgets::reticulum_ping::show_timeout( + ping_event->destination_hash, + ping_event->elapsed_ms); + } + delete event; + return true; + } case sys::EventType::ChatSendResult: handleChatSendResultFeedback( app_context, diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index 0b13ce61..82dba022 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -9,12 +9,16 @@ #include "app/app_facades.h" #include "ble/ble_manager.h" #include "board/BoardBase.h" +#if defined(ARDUINO_T_LORA_PAGER) +#include "boards/tlora_pager/tlora_pager_board.h" +#endif #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/arduino_common/chat/infra/mesh_mqtt_client_runtime.h" #include "platform/esp/arduino_common/device_identity.h" #include "platform/esp/arduino_common/reticulum_call_audio_runtime.h" +#include "platform/esp/boards/board_runtime.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/settings_store.h" @@ -28,6 +32,9 @@ #include "ui/localization.h" #include "ui/runtime/ui_feedback.h" +#include +#include + #ifndef APP_EVENT_LOG_ENABLE #define APP_EVENT_LOG_ENABLE 0 #endif @@ -56,9 +63,15 @@ constexpr int kContactAlertsNone = 0; constexpr int kContactAlertsContacts = 1; constexpr int kContactAlertsAll = 2; constexpr uint32_t kContactAlertModeCacheMs = 10000; +constexpr uint32_t kIncomingCallToneGapMs = 600; +constexpr uint32_t kIncomingCallToneStopTimeoutMs = 500; +constexpr uint32_t kIncomingCallToneTaskStackBytes = 5 * 1024; +constexpr UBaseType_t kIncomingCallToneTaskPriority = tskIDLE_PRIORITY + 1; bool s_call_realtime_resources_held = false; bool s_call_realtime_hooks_registered = false; +TaskHandle_t s_incoming_call_tone_task = nullptr; +volatile bool s_incoming_call_tone_stop_requested = false; void pauseCallRealtimeResources() { @@ -82,16 +95,123 @@ void resumeCallRealtimeResources() s_call_realtime_resources_held = false; } +BoardBase* resolveCallBoard() +{ + const auto handles = ::platform::esp::boards::resolveAppContextInitHandles(); + return handles.board; +} + +void playIncomingCallTone(BoardBase& board) +{ +#if defined(ARDUINO_T_LORA_PAGER) + static_cast<::boards::tlora_pager::TLoRaPagerBoard&>(board).playIncomingCallTone( + &s_incoming_call_tone_stop_requested); +#else + if (!s_incoming_call_tone_stop_requested) + { + board.playMessageTone(); + } +#endif +} + +void incomingCallToneTask(void* argument) +{ + auto* board = static_cast(argument); + while (!s_incoming_call_tone_stop_requested && + ::platform::ui::reticulum_call::realtime_phase() == + ::platform::ui::reticulum_call::RealtimePhase::IncomingRinging) + { + playIncomingCallTone(*board); + const TickType_t gap_started = xTaskGetTickCount(); + while (!s_incoming_call_tone_stop_requested && + ::platform::ui::reticulum_call::realtime_phase() == + ::platform::ui::reticulum_call::RealtimePhase::IncomingRinging && + pdTICKS_TO_MS(xTaskGetTickCount() - gap_started) < + kIncomingCallToneGapMs) + { + vTaskDelay(pdMS_TO_TICKS(20)); + } + } + s_incoming_call_tone_task = nullptr; + vTaskDelete(nullptr); +} + +bool startIncomingCallTone() +{ + if (s_incoming_call_tone_task) + { + return true; + } + BoardBase* board = resolveCallBoard(); + if (!board) + { + return false; + } + s_incoming_call_tone_stop_requested = false; + const BaseType_t rc = xTaskCreate(incomingCallToneTask, + "rt_call_ring", + kIncomingCallToneTaskStackBytes, + board, + kIncomingCallToneTaskPriority, + &s_incoming_call_tone_task); + if (rc != pdPASS) + { + s_incoming_call_tone_task = nullptr; + return false; + } + return true; +} + +bool stopIncomingCallTone() +{ + s_incoming_call_tone_stop_requested = true; + const TaskHandle_t task = s_incoming_call_tone_task; + if (!task) + { + return true; + } + if (xTaskGetCurrentTaskHandle() == task) + { + return false; + } + + const TickType_t started = xTaskGetTickCount(); + while (s_incoming_call_tone_task && + pdTICKS_TO_MS(xTaskGetTickCount() - started) < + kIncomingCallToneStopTimeoutMs) + { + vTaskDelay(pdMS_TO_TICKS(5)); + } + return s_incoming_call_tone_task == nullptr; +} + bool callRealtimeBeginRinging(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) { pauseCallRealtimeResources(); - return ::platform::ui::wifi_access::enter_call_ringing(link_id); + const bool entered = ::platform::ui::wifi_access::enter_call_ringing(link_id); + if (entered && !startIncomingCallTone()) + { + std::printf("[Reticulum][CallTone] start failed\n"); + } + return entered; } bool callRealtimeBeginExclusive(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) { + const bool was_ringing = + ::platform::ui::reticulum_call::realtime_phase() == + ::platform::ui::reticulum_call::RealtimePhase::IncomingRinging; + if (!stopIncomingCallTone()) + { + std::printf("[Reticulum][CallTone] stop timeout\n"); + return false; + } if (!::platform::ui::wifi_access::enter_call_exclusive(link_id)) { + if (was_ringing) + { + (void)startIncomingCallTone(); + } return false; } pauseCallRealtimeResources(); @@ -101,12 +221,14 @@ bool callRealtimeBeginExclusive(const uint8_t link_id[::platform::ui::reticulum_ void callRealtimeBeginClosing(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize], bool keep_exclusive) { + (void)stopIncomingCallTone(); pauseCallRealtimeResources(); ::platform::ui::wifi_access::enter_call_closing(link_id, keep_exclusive); } void callRealtimeEnd(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) { + (void)stopIncomingCallTone(); ::platform::ui::wifi_access::exit_call(link_id); resumeCallRealtimeResources(); } @@ -243,6 +365,7 @@ void notifyNodeInfoUpdate(app::IAppFacade& app_context, const sys::NodeInfoUpdat BackgroundTaskStartResult startBackgroundTasks(LoraBoard* board, chat::IMeshAdapter* adapter) { ensureCallRealtimeHooksRegistered(); + ensureReticulumCallAudioRuntimeRegistered(); if (!board) { return BackgroundTaskStartResult::NotSupported; diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index 5967c0f9..44582fec 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -8,6 +8,8 @@ #include "chat/domain/contact_types.h" #include "chat/domain/reticulum_identity.h" #include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "chat/infra/reticulum/audio_call_wire.h" +#include "chat/infra/reticulum/lxst_telephony_wire.h" #include "chat/time_utils.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_link_runtime.h" @@ -15,19 +17,21 @@ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_service_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_resource_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h" +#include "platform/esp/common/reticulum_crypto_compat.h" +#include "platform/esp/common/reticulum_runtime_compat.h" +#include "platform/ui/gps_runtime.h" #include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/reticulum_directory_runtime.h" #include "platform/ui/reticulum_page_runtime.h" #include "platform/ui/screen_runtime.h" #include "sys/event_bus.h" -#include -#include #include #include #include #include +#include #include #include #include @@ -66,6 +70,10 @@ constexpr uint32_t kLinkKeepaliveMaxMs = 360000; constexpr float kLinkKeepaliveMaxRttS = 1.75f; constexpr uint32_t kLinkKeepaliveTimeoutFactor = 4; constexpr uint32_t kLinkStaleGraceMs = 5000; +constexpr uint32_t kCallIdentifyTimeoutMs = 15000; +constexpr uint32_t kCallRingTimeoutMs = 60000; +constexpr uint32_t kCallDialTimeoutMs = 70000; +constexpr uint32_t kCallMediaSetupTimeoutMs = 5000; constexpr uint32_t kResourceTransferTtlMs = 60000; constexpr uint32_t kResourceWindowSize = 4; constexpr size_t kMaxPropagationEntries = 64; @@ -93,12 +101,34 @@ constexpr uint8_t kResourceFlagHasMetadata = 1U << 5U; constexpr uint32_t kPacketFilterTtlMs = 30000; constexpr uint32_t kReverseEntryTtlMs = 60000; constexpr uint32_t kLinkRelayTtlMs = 300000; +constexpr uint32_t kPathTtlMs = 7UL * 24UL * 60UL * 60UL * 1000UL; +constexpr size_t kMaxPendingPingReceipts = 4; +constexpr uint32_t kPendingPingReceiptTtlMs = 30000; +constexpr uint32_t kPendingPingSendRetryMs = 1500; constexpr uint32_t kDirectoryAddressRefreshIntervalS = 300; constexpr uint8_t kMaxTransportHops = 128; constexpr const char* kAnonymousPeerDisplayName = "Anonymous Peer"; constexpr const char* kAnonymousNodeDisplayName = "Anonymous Node"; constexpr uint8_t kPropagationMetaName = 0x01; +const char* pathAnnounceDecisionLabel(runtime::PathAnnounceDecision decision) +{ + switch (decision) + { + case runtime::PathAnnounceDecision::AcceptNew: + return "new"; + case runtime::PathAnnounceDecision::AcceptNewer: + return "newer"; + case runtime::PathAnnounceDecision::AcceptExpired: + return "expired"; + case runtime::PathAnnounceDecision::RejectReplay: + return "replay"; + case runtime::PathAnnounceDecision::RejectStale: + return "stale"; + } + return "unknown"; +} + void formatHashPrefix(const uint8_t* hash, char* out, size_t out_len) { if (!out || out_len == 0) @@ -513,11 +543,56 @@ void destinationHashForAspect(const uint8_t identity_hash[reticulum::kTruncatedH destinationHashForServiceAspect(identity_hash, "lxmf", aspect, out_hash); } -void callAudioDestinationHashForIdentity( +void callDestinationHashForIdentity( const uint8_t identity_hash[reticulum::kTruncatedHashSize], + ReticulumCallWireProfile profile, uint8_t out_hash[reticulum::kTruncatedHashSize]) { - destinationHashForServiceAspect(identity_hash, "call", "audio", out_hash); + if (profile == ReticulumCallWireProfile::MeshChatCallAudio) + { + destinationHashForServiceAspect(identity_hash, "call", "audio", out_hash); + } + else + { + destinationHashForServiceAspect(identity_hash, "lxst", "telephony", out_hash); + } +} + +::platform::ui::reticulum_call::WireProfile callRuntimeWireProfile( + ReticulumCallWireProfile profile) +{ + return profile == ReticulumCallWireProfile::MeshChatCallAudio + ? ::platform::ui::reticulum_call::WireProfile::MeshChatCallAudio + : ::platform::ui::reticulum_call::WireProfile::SidebandLxst; +} + +reticulum::audio_call::Codec2Mode callAudioCodec2Mode(uint16_t lxst_profile) +{ + reticulum::audio_call::Codec2Mode mode = + reticulum::audio_call::Codec2Mode::Mode3200; + (void)reticulum::lxst::profileToCodec2Mode(lxst_profile, &mode); + return mode; +} + +::platform::ui::reticulum_call::Codec2Mode callRuntimeCodec2Mode( + ReticulumCallWireProfile wire_profile, + uint16_t lxst_profile) +{ + if (wire_profile == ReticulumCallWireProfile::MeshChatCallAudio) + { + return ::platform::ui::reticulum_call::Codec2Mode::Mode1200; + } + + switch (callAudioCodec2Mode(lxst_profile)) + { + case reticulum::audio_call::Codec2Mode::Mode700C: + return ::platform::ui::reticulum_call::Codec2Mode::Mode700C; + case reticulum::audio_call::Codec2Mode::Mode1600: + return ::platform::ui::reticulum_call::Codec2Mode::Mode1600; + case reticulum::audio_call::Codec2Mode::Mode3200: + default: + return ::platform::ui::reticulum_call::Codec2Mode::Mode3200; + } } void fillRandomBytes(uint8_t* out, size_t len) @@ -760,9 +835,30 @@ bool isCallAudioAnnounce(const reticulum::ParsedAnnounce& announce) return false; } + uint8_t meshchat_name_hash[reticulum::kNameHashSize] = {}; + uint8_t lxst_name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("call", "audio", meshchat_name_hash); + reticulum::computeNameHash("lxst", "telephony", lxst_name_hash); + return hashesEqual(meshchat_name_hash, + announce.name_hash, + sizeof(meshchat_name_hash)) || + hashesEqual(lxst_name_hash, + announce.name_hash, + sizeof(lxst_name_hash)); +} + +bool isLxstTelephonyAnnounce(const reticulum::ParsedAnnounce& announce) +{ + if (!announce.valid || !announce.name_hash) + { + return false; + } + uint8_t expected_name_hash[reticulum::kNameHashSize] = {}; - reticulum::computeNameHash("call", "audio", expected_name_hash); - return hashesEqual(expected_name_hash, announce.name_hash, sizeof(expected_name_hash)); + reticulum::computeNameHash("lxst", "telephony", expected_name_hash); + return hashesEqual(expected_name_hash, + announce.name_hash, + sizeof(expected_name_hash)); } bool isNomadNetworkNodeAnnounce(const reticulum::ParsedAnnounce& announce) @@ -1102,6 +1198,177 @@ bool LxmfAdapter::sendText(ChannelId channel, const std::string& text, return result.ok; } +bool LxmfAdapter::dispatchLxmfPayload(PeerInfo& peer, + const uint8_t* packed_payload, + size_t packed_payload_len, + bool track_user_message, + OutboundLxmfDispatch* out_dispatch) +{ + if (!packed_payload || packed_payload_len == 0 || !out_dispatch) + { + return false; + } + *out_dispatch = OutboundLxmfDispatch{}; + + if (shouldRequestPath(peer)) + { + (void)sendPathRequest(peer); + } + + std::vector signed_part(kSignedPartMaxLen, 0); + size_t signed_part_len = signed_part.size(); + if (!buildSignedPart(peer.destination_hash, + identity_.destinationHash(), + packed_payload, + packed_payload_len, + signed_part.data(), + &signed_part_len, + out_dispatch->message_hash)) + { + out_dispatch->failure = MeshOperationFailure::EncodeFailed; + return false; + } + + uint8_t signature[reticulum::kSignatureSize] = {}; + std::vector lxmf_message(kMaxLxmfMessageLen, 0); + size_t lxmf_message_len = lxmf_message.size(); + if (!identity_.sign(signed_part.data(), signed_part_len, signature) || + !packMessage(peer.destination_hash, + identity_.destinationHash(), + signature, + packed_payload, + packed_payload_len, + lxmf_message.data(), + &lxmf_message_len)) + { + out_dispatch->failure = MeshOperationFailure::CryptoFailed; + return false; + } + + out_dispatch->message_id = messageIdFromHash(out_dispatch->message_hash); + LinkSession* active_link = + findActiveLinkSessionByDestination(peer.destination_hash, + LocalDestinationKind::Delivery); + const bool use_opportunistic = !active_link && peerHasUsableRatchet(peer); + out_dispatch->path = + active_link ? "link" : (use_opportunistic ? "opportunistic" : "deferred_link"); + if (active_link) + { + out_dispatch->ok = lxmf_message_len <= active_link->mdu + ? sendLinkPacket(*active_link, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + lxmf_message.data(), + lxmf_message_len, + true) + : queueOutgoingResource(*active_link, + lxmf_message.data(), + lxmf_message_len, + 0, + nullptr, + 0); + if (out_dispatch->ok) + { + (void)sendLinkIdentify(*active_link); + } + } + else if (use_opportunistic) + { + std::vector packet(kMaxPacketLen, 0); + size_t packet_len = packet.size(); + out_dispatch->ok = + buildSignedMessagePacket(peer, + packed_payload, + packed_payload_len, + packet.data(), + &packet_len, + out_dispatch->message_hash) && + routeAndSendPacket(packet.data(), packet_len, true); + } + + if (!active_link && !out_dispatch->ok) + { + bool started = false; + LinkSession* session = + ensureOutboundLinkSession(peer, LocalDestinationKind::Delivery, &started); + if (session) + { + runtime::DeferredLinkPayload deferred{}; + deferred.payload.assign(lxmf_message.data(), + lxmf_message.data() + lxmf_message_len); + deferred.message_id = track_user_message + ? out_dispatch->message_id + : 0; + session->deferred_payloads.push_back(std::move(deferred)); + if (session->state == LinkState::Active) + { + flushDeferredLinkPayloads(*session); + } + out_dispatch->ok = true; + out_dispatch->result_event_deferred = track_user_message; + } + } + + if (!out_dispatch->ok) + { + out_dispatch->failure = MeshOperationFailure::RadioTxFailed; + } + return out_dispatch->ok; +} + +bool LxmfAdapter::respondToSidebandTelemetryRequest( + PeerInfo& peer, + const SidebandTelemetryRequest& request) +{ + if (!request.valid || !config_.reticulum_allow_location_requests) + { + return false; + } + + const auto fix = ::platform::ui::gps::get_data(); + if (!fix.valid) + { + Serial.printf("[LXMF][Sideband] telemetry response skipped peer=%08lX reason=gps_no_fix\n", + static_cast(peer.node_id)); + return false; + } + + SidebandTelemetryLocation location{}; + location.valid = true; + location.latitude_e6 = static_cast(std::llround(fix.lat * 1000000.0)); + location.longitude_e6 = static_cast(std::llround(fix.lng * 1000000.0)); + location.altitude_cm = fix.has_alt + ? static_cast(std::llround(fix.alt_m * 100.0)) + : 0; + location.accuracy_cm = 0; + location.timestamp = currentTimestampSeconds(); + + std::vector packed_payload(kMaxLxmfMessageLen, 0); + size_t packed_payload_len = packed_payload.size(); + if (!encodeSidebandTelemetryLocationPayload( + static_cast(location.timestamp), + location, + packed_payload.data(), + &packed_payload_len)) + { + return false; + } + + OutboundLxmfDispatch dispatch{}; + const bool sent = dispatchLxmfPayload(peer, + packed_payload.data(), + packed_payload_len, + false, + &dispatch); + Serial.printf("[LXMF][Sideband] telemetry response peer=%08lX sent=%u path=%s timebase=%lu collector=%u\n", + static_cast(peer.node_id), + sent ? 1U : 0U, + dispatch.path, + static_cast(request.timebase), + request.collector_request ? 1U : 0U); + return sent; +} + MeshSendResult LxmfAdapter::sendTextDetailed(ChannelId channel, const std::string& text, MessageId forced_msg_id, @@ -1149,125 +1416,30 @@ MeshSendResult LxmfAdapter::sendTextDetailed(ChannelId channel, peer_dest_full, sizeof(peer_dest_full)); - if (shouldRequestPath(*peer_info)) - { - (void)sendPathRequest(*peer_info); - } - - uint8_t packed_payload[kMaxLxmfMessageLen] = {}; - size_t packed_payload_len = sizeof(packed_payload); + std::vector packed_payload(kMaxLxmfMessageLen, 0); + size_t packed_payload_len = packed_payload.size(); if (!encodeTextPayload(static_cast(currentTimestampSeconds()), "", text.c_str(), - packed_payload, + packed_payload.data(), &packed_payload_len)) { return MeshSendResult::fail(MeshOperationFailure::EncodeFailed); } - uint8_t message_hash[reticulum::kFullHashSize] = {}; - uint8_t signed_part[kSignedPartMaxLen] = {}; - size_t signed_part_len = sizeof(signed_part); - if (!buildSignedPart(peer_info->destination_hash, - identity_.destinationHash(), - packed_payload, - packed_payload_len, - signed_part, - &signed_part_len, - message_hash)) - { - return MeshSendResult::fail(MeshOperationFailure::EncodeFailed); - } - - uint8_t signature[reticulum::kSignatureSize] = {}; - uint8_t lxmf_message[kMaxLxmfMessageLen] = {}; - size_t lxmf_message_len = sizeof(lxmf_message); - if (!identity_.sign(signed_part, signed_part_len, signature) || - !packMessage(peer_info->destination_hash, - identity_.destinationHash(), - signature, - packed_payload, - packed_payload_len, - lxmf_message, - &lxmf_message_len)) - { - return MeshSendResult::fail(MeshOperationFailure::CryptoFailed); - } - - const MessageId message_id = messageIdFromHash(message_hash); - LinkSession* active_link = - findActiveLinkSessionByDestination(peer_info->destination_hash, LocalDestinationKind::Delivery); - bool ok = false; - bool send_result_event_deferred = false; - const bool use_opportunistic = !active_link && peerHasUsableRatchet(*peer_info); - const char* send_path = - active_link ? "link" : (use_opportunistic ? "opportunistic" : "deferred_link"); - if (active_link) - { - if (lxmf_message_len <= active_link->mdu) - { - ok = sendLinkPacket(*active_link, - reticulum::PacketType::Data, - reticulum::PacketContext::None, - lxmf_message, - lxmf_message_len, - true); - } - else - { - ok = queueOutgoingResource(*active_link, - lxmf_message, - lxmf_message_len, - 0, - nullptr, - 0); - } - } - else if (use_opportunistic) - { - uint8_t packet[kMaxPacketLen] = {}; - size_t packet_len = sizeof(packet); - if (buildSignedMessagePacket(*peer_info, - packed_payload, - packed_payload_len, - packet, - &packet_len, - message_hash) && - routeAndSendPacket(packet, packet_len, true)) - { - ok = true; - } - } - else - { - Serial.printf("[LXMF][DirectTX] opportunistic_skip peer=%08lX dest=%s reason=no_ratchet\n", - static_cast(peer_info->node_id), - peer_hash); - } - - if (!active_link && !ok) - { - bool started = false; - LinkSession* session = - ensureOutboundLinkSession(*peer_info, LocalDestinationKind::Delivery, &started); - if (session) - { - send_path = "deferred_link"; - runtime::DeferredLinkPayload deferred{}; - deferred.payload.assign(lxmf_message, lxmf_message + lxmf_message_len); - deferred.message_id = message_id; - session->deferred_payloads.push_back(std::move(deferred)); - if (session->state == LinkState::Active) - { - flushDeferredLinkPayloads(*session); - } - ok = true; - send_result_event_deferred = true; - } - } + OutboundLxmfDispatch dispatch{}; + const bool ok = dispatchLxmfPayload(*peer_info, + packed_payload.data(), + packed_payload_len, + true, + &dispatch); + const MessageId message_id = dispatch.message_id; + const bool send_result_event_deferred = dispatch.result_event_deferred; char message_hash_prefix[12] = {}; - formatHashPrefix(message_hash, message_hash_prefix, sizeof(message_hash_prefix)); + formatHashPrefix(dispatch.message_hash, + message_hash_prefix, + sizeof(message_hash_prefix)); const auto& tx_result = interfaces_.lastTxResult(); Serial.printf("[LXMF][DirectTX] result ok=%u msg=%lu hash=%s peer=%08lX name=\"%s\" dest=%s dest_full=%s path=%s event_deferred=%u bearer=%s complete=%u payload_len=%u text=\"%s\"\n", ok ? 1U : 0U, @@ -1277,7 +1449,7 @@ MeshSendResult LxmfAdapter::sendTextDetailed(ChannelId channel, peer_info->display_name[0] != '\0' ? peer_info->display_name : "", peer_hash, peer_dest_full, - send_path, + dispatch.path, send_result_event_deferred ? 1U : 0U, txBearerName(tx_result), tx_result.reachedRequiredInterfaces() ? 1U : 0U, @@ -1285,7 +1457,7 @@ MeshSendResult LxmfAdapter::sendTextDetailed(ChannelId channel, text_preview); MeshSendResult result = ok ? MeshSendResult::success(message_id) - : MeshSendResult::fail(MeshOperationFailure::RadioTxFailed, message_id); + : MeshSendResult::fail(dispatch.failure, message_id); result.reticulum_identity = reticulumIdentityForPeer(*peer_info); if (!send_result_event_deferred) { @@ -1562,6 +1734,10 @@ bool LxmfAdapter::sendAppData(ChannelId channel, uint32_t portnum, nullptr, 0); } + if (ok) + { + (void)sendLinkIdentify(*active_link); + } } else { @@ -1752,6 +1928,11 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( { return MeshActionResult::fail(MeshOperationFailure::NotReady); } + if (::platform::ui::reticulum_call::realtime_phase() != + ::platform::ui::reticulum_call::RealtimePhase::Idle) + { + return MeshActionResult::fail(MeshOperationFailure::Busy); + } const PeerInfo* peer = findOrLoadPeerByDestinationHash(destination.destination_hash); if (!peer && !isZeroBytes(destination.identity_hash, sizeof(destination.identity_hash))) @@ -1775,8 +1956,12 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( return MeshActionResult::fail(MeshOperationFailure::PeerKeyMissing); } + const ReticulumCallWireProfile wire_profile = + config_.reticulum_call_wire_profile; uint8_t call_destination_hash[reticulum::kTruncatedHashSize] = {}; - callAudioDestinationHashForIdentity(remote_identity_hash, call_destination_hash); + callDestinationHashForIdentity(remote_identity_hash, + wire_profile, + call_destination_hash); if (isZeroBytes(call_destination_hash, sizeof(call_destination_hash))) { return MeshActionResult::fail(MeshOperationFailure::EncodeFailed); @@ -1789,10 +1974,11 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( } const PathEntry* path = findPath(call_destination_hash); + bool path_requested = false; if (!path) { - (void)sendPathRequestForDestination(call_destination_hash); - return MeshActionResult::fail(MeshOperationFailure::NotReady, 1); + path_requested = + sendPathRequestForDestination(call_destination_hash); } LinkSession& session = runtime::appendLinkSession(links_, kMaxLinkSessions); @@ -1803,10 +1989,15 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( session.destination = LocalDestinationKind::CallAudio; session.state = LinkState::Pending; session.close_reason = LinkCloseReason::None; - session.expected_hops = path->hops; + session.expected_hops = path ? path->hops : 0; session.remote_identity_known = !isZeroBytes(remote_identity_hash, sizeof(remote_identity_hash)); session.validated = false; + session.call_wire_profile = wire_profile; + session.lxst_profile = reticulum::lxst::kProfileBandwidthLow; + session.lxst_local_status = reticulum::lxst::kStatusCalling; + session.lxst_remote_status = reticulum::lxst::kStatusCalling; + session.call_status_ms = session.created_ms; session.keepalive_interval_ms = kLinkKeepaliveMaxMs; session.stale_timeout_ms = kLinkKeepaliveMaxMs * 2U; copyHash(session.remote_destination_hash, @@ -1854,8 +2045,21 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( sizeof(call_peer.identity_hash)); } call_peer.incoming = false; + call_peer.wire_profile = callRuntimeWireProfile(session.call_wire_profile); + call_peer.codec2_mode = + callRuntimeCodec2Mode(session.call_wire_profile, session.lxst_profile); ::platform::ui::reticulum_call::update_peer(call_peer); + char call_hash[12] = {}; + formatHashPrefix(call_destination_hash, + call_hash, + sizeof(call_hash)); + Serial.printf("[LXMF][CallTX] start dest=%s wire=%u path=%u requested=%u link_pending=1\n", + call_hash, + static_cast(wire_profile), + path ? 1U : 0U, + path_requested ? 1U : 0U); + return MeshActionResult::success(); } @@ -1903,26 +2107,36 @@ MeshActionResult LxmfAdapter::pingReticulumDestination( } if (!peer) { - const bool path_requested = - sendPathRequestForDestination(destination.destination_hash); - Serial.printf("[LXMF][PingTX] path_pending dest=%s requested=%u\n", - dest_hash, - path_requested ? 1U : 0U); - return MeshActionResult::fail(MeshOperationFailure::NotReady, - path_requested ? 1 : 2); + return queuePendingReticulumPing(destination.destination_hash); } if (isZeroBytes(peer->identity_hash, sizeof(peer->identity_hash)) || + isZeroBytes(peer->sig_pub, sizeof(peer->sig_pub)) || (!peerHasUsableRatchet(*peer) && isZeroBytes(peer->enc_pub, sizeof(peer->enc_pub)))) { - Serial.printf("[LXMF][PingTX] reject reason=peer_key_missing dest=%s peer=%08lX\n", + Serial.printf("[LXMF][PingTX] wait reason=peer_key_missing dest=%s peer=%08lX\n", dest_hash, static_cast(peer->node_id)); - return MeshActionResult::fail(MeshOperationFailure::PeerKeyMissing); + return queuePendingReticulumPing(destination.destination_hash); } - if (shouldRequestPath(*peer)) + return sendReticulumPingToPeer(*peer, millis()); +} + +MeshActionResult LxmfAdapter::sendReticulumPingToPeer( + PeerInfo& peer, + uint32_t operation_started_ms) +{ + char dest_hash[12] = {}; + char dest_full[(reticulum::kTruncatedHashSize * 2U) + 1U] = {}; + formatHashPrefix(peer.destination_hash, dest_hash, sizeof(dest_hash)); + formatHashHex(peer.destination_hash, + sizeof(peer.destination_hash), + dest_full, + sizeof(dest_full)); + + if (shouldRequestPath(peer)) { - const bool requested = sendPathRequest(*peer); + const bool requested = sendPathRequest(peer); Serial.printf("[LXMF][PingTX] path_refresh dest=%s requested=%u\n", dest_hash, requested ? 1U : 0U); @@ -1930,7 +2144,7 @@ MeshActionResult LxmfAdapter::pingReticulumDestination( std::vector packet(kMaxPacketLen, 0); size_t packet_len = packet.size(); - if (!buildEncryptedPacketForPeer(*peer, nullptr, 0, packet.data(), &packet_len)) + if (!buildEncryptedPacketForPeer(peer, nullptr, 0, packet.data(), &packet_len)) { Serial.printf("[LXMF][PingTX] reject reason=build_packet_failed dest=%s\n", dest_hash); @@ -1938,18 +2152,155 @@ MeshActionResult LxmfAdapter::pingReticulumDestination( } const bool ok = routeAndSendPacket(packet.data(), packet_len, true); + if (ok) + { + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + reticulum::computePacketHash(packet.data(), packet_len, packet_hash); + runtime::notePendingPingReceipt(transport_, + packet_hash, + peer.destination_hash, + peer.sig_pub, + operation_started_ms == 0 ? millis() + : operation_started_ms, + kMaxPendingPingReceipts); + } const auto& tx_result = interfaces_.lastTxResult(); - Serial.printf("[LXMF][PingTX] raw_send ok=%u dest=%s dest_full=%s bearer=%s complete=%u packet_len=%u\n", + Serial.printf("[LXMF][PingTX] raw_send ok=%u dest=%s dest_full=%s bearer=%s complete=%u receipt=%u packet_len=%u\n", ok ? 1U : 0U, dest_hash, dest_full, txBearerName(tx_result), tx_result.reachedRequiredInterfaces() ? 1U : 0U, + ok ? 1U : 0U, static_cast(packet_len)); return ok ? MeshActionResult::success() : MeshActionResult::fail(MeshOperationFailure::RadioTxFailed); } +MeshActionResult LxmfAdapter::queuePendingReticulumPing( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) +{ + char dest_hash[12] = {}; + formatHashPrefix(destination_hash, dest_hash, sizeof(dest_hash)); + for (const PendingPingRequest& pending : pending_ping_requests_) + { + if (hashesEqual(pending.destination_hash, + destination_hash, + sizeof(pending.destination_hash))) + { + Serial.printf("[LXMF][PingTX] path_pending dest=%s queued=1 duplicate=1\n", + dest_hash); + MeshActionResult result = MeshActionResult::success(); + result.detail = 1; + return result; + } + } + + if (pending_ping_requests_.size() >= kMaxPendingPingRequests) + { + Serial.printf("[LXMF][PingTX] reject reason=pending_full dest=%s depth=%u\n", + dest_hash, + static_cast(pending_ping_requests_.size())); + return MeshActionResult::fail(MeshOperationFailure::Busy); + } + + PendingPingRequest request{}; + copyHash(request.destination_hash, + destination_hash, + sizeof(request.destination_hash)); + request.created_ms = millis(); + request.last_path_request_ms = request.created_ms; + pending_ping_requests_.push_back(request); + + const bool path_requested = sendPathRequestForDestination(destination_hash); + Serial.printf("[LXMF][PingTX] path_pending dest=%s queued=1 requested=%u depth=%u\n", + dest_hash, + path_requested ? 1U : 0U, + static_cast(pending_ping_requests_.size())); + MeshActionResult result = MeshActionResult::success(); + result.detail = 1; + return result; +} + +void LxmfAdapter::pumpPendingPingRequests() +{ + const uint32_t now_ms = millis(); + const bool call_preempt_active = + ::platform::ui::reticulum_call::resource_preempt_active(); + for (std::size_t index = 0; index < pending_ping_requests_.size();) + { + PendingPingRequest& request = pending_ping_requests_[index]; + const uint32_t elapsed_ms = now_ms - request.created_ms; + char dest_hash[12] = {}; + formatHashPrefix(request.destination_hash, dest_hash, sizeof(dest_hash)); + + if (request.created_ms == 0 || elapsed_ms > kPendingPingReceiptTtlMs) + { + Serial.printf("[LXMF][PingRX] timeout dest=%s elapsed_ms=%lu stage=path\n", + dest_hash, + static_cast(elapsed_ms)); + sys::EventBus::publish( + new sys::ReticulumPingResultEvent( + request.destination_hash, + sys::ReticulumPingResult::Timeout, + elapsed_ms), + 100); + pending_ping_requests_.erase( + pending_ping_requests_.begin() + + static_cast(index)); + continue; + } + + if (call_preempt_active) + { + ++index; + continue; + } + + PeerInfo* peer = + findOrLoadPeerByDestinationHash(request.destination_hash); + const bool peer_ready = + peer && + !isZeroBytes(peer->identity_hash, sizeof(peer->identity_hash)) && + !isZeroBytes(peer->sig_pub, sizeof(peer->sig_pub)) && + (peerHasUsableRatchet(*peer) || + !isZeroBytes(peer->enc_pub, sizeof(peer->enc_pub))); + if (peer_ready && + (request.last_send_attempt_ms == 0 || + (now_ms - request.last_send_attempt_ms) >= kPendingPingSendRetryMs)) + { + request.last_send_attempt_ms = now_ms; + Serial.printf("[LXMF][PingTX] dispatch_after_path dest=%s elapsed_ms=%lu\n", + dest_hash, + static_cast(elapsed_ms)); + const MeshActionResult result = + sendReticulumPingToPeer(*peer, request.created_ms); + if (result.ok) + { + pending_ping_requests_.erase( + pending_ping_requests_.begin() + + static_cast(index)); + continue; + } + } + else if (!peer_ready && + (request.last_path_request_ms == 0 || + (now_ms - request.last_path_request_ms) >= + kPathRequestMinIntervalMs)) + { + request.last_path_request_ms = now_ms; + const bool requested = + sendPathRequestForDestination(request.destination_hash); + Serial.printf("[LXMF][PingTX] path_retry dest=%s requested=%u elapsed_ms=%lu\n", + dest_hash, + requested ? 1U : 0U, + static_cast(elapsed_ms)); + } + + ++index; + } +} + MeshActionResult LxmfAdapter::persistReticulumPeer( const ReticulumPeerIdentity& destination, bool favorite) @@ -2212,12 +2563,12 @@ void LxmfAdapter::processRuntime() if (::platform::ui::reticulum_call::realtime_mode_active()) { processRadioPackets(budget); + pumpPendingPingRequests(); pumpReticulumAudioCall(); return; } cullTransportState(); - cullLinkSessions(); const runtime::PropagationRuntimeLimits propagation_limits{ kMaxPropagationEntries, kMaxPropagationTransients, @@ -2230,6 +2581,7 @@ void LxmfAdapter::processRuntime() propagation_limits); processRadioPackets(budget); + pumpPendingPingRequests(); pumpReticulumAudioCall(); pumpNomadPageRequests(); processDeferredDiscoveryPackets(budget); @@ -2388,8 +2740,6 @@ bool LxmfAdapter::processOneRadioPacket( return false; } - rememberPacket(packet_hash); - if (!deferred_replay && ingress_wifi && !shouldProcessWifiIngressPacket(parsed)) { if (budget.phase && std::strcmp(budget.phase, "nomad") == 0 && @@ -2414,6 +2764,8 @@ bool LxmfAdapter::processOneRadioPacket( return false; } + rememberPacket(packet_hash); + if (parsed.packet_type == reticulum::PacketType::Announce) { return handleAnnouncePacket(packet, @@ -2632,11 +2984,18 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, bool app_data_ok = false; if (kind == LocalDestinationKind::CallAudio) { - const char* display_name = effectiveDisplayName(); - app_data_len = display_name ? std::min(strlen(display_name), sizeof(app_data)) : 0; - if (app_data_len != 0) + app_data_len = 0; + if (config_.reticulum_call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio) { - memcpy(app_data, display_name, app_data_len); + const char* display_name = effectiveDisplayName(); + app_data_len = display_name + ? std::min(strlen(display_name), sizeof(app_data)) + : 0; + if (app_data_len != 0) + { + memcpy(app_data, display_name, app_data_len); + } } app_data_ok = true; } @@ -2672,7 +3031,15 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, uint8_t name_hash[reticulum::kNameHashSize] = {}; if (kind == LocalDestinationKind::CallAudio) { - reticulum::computeNameHash("call", "audio", name_hash); + if (config_.reticulum_call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio) + { + reticulum::computeNameHash("call", "audio", name_hash); + } + else + { + reticulum::computeNameHash("lxst", "telephony", name_hash); + } } else { @@ -2883,10 +3250,58 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len return false; } + LocalDestinationKind local_kind = LocalDestinationKind::Delivery; + const bool local_destination = + isLocalDestinationHash(packet.destination_hash, &local_kind); + if (local_destination) + { + char destination_hex[(reticulum::kTruncatedHashSize * 2U) + 1U] = {}; + formatHashHex(packet.destination_hash, + reticulum::kTruncatedHashSize, + destination_hex, + sizeof(destination_hex)); + Serial.printf("[LXMF][AnnounceRX] ignore reason=local_destination dest=%s kind=%s\n", + destination_hex, + localDestinationKindLabel(local_kind)); + return true; + } + if (packet.hops > kMaxTransportHops) + { + Serial.printf("[LXMF][AnnounceRX] ignore reason=max_hops hops=%u\n", + static_cast(packet.hops)); + return true; + } + + const uint32_t now_ms = millis(); + const PathEntry* existing_path = runtime::findPath(transport_, packet.destination_hash); + const runtime::PathAnnounceDecision path_decision = + runtime::evaluatePathAnnounce(existing_path, + packet.hops, + announce.random_hash, + now_ms, + kPathTtlMs); + if (!runtime::pathAnnounceAccepted(path_decision)) + { + char destination_hex[(reticulum::kTruncatedHashSize * 2U) + 1U] = {}; + formatHashHex(packet.destination_hash, + reticulum::kTruncatedHashSize, + destination_hex, + sizeof(destination_hex)); + Serial.printf("[LXMF][AnnounceRX] ignore reason=path_%s dest=%s hops=%u previous_hops=%u\n", + pathAnnounceDecisionLabel(path_decision), + destination_hex, + static_cast(packet.hops), + static_cast(existing_path ? existing_path->hops : 0)); + return true; + } + PathEntry& path = upsertPath(packet.destination_hash); const uint32_t now_s = currentTimestampSeconds(); - path.hops = packet.hops; - path.last_seen_s = now_s; + runtime::applyPathAnnounce(path, + packet.hops, + announce.random_hash, + now_ms, + now_s); path.direct = (packet.transport_id == nullptr); resolvePendingPathRequest(packet.destination_hash); if (packet.transport_id) @@ -2934,9 +3349,9 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len const bool delivery_announce = isLxmfDeliveryAnnounce(announce); const bool propagation_announce = isLxmfPropagationAnnounce(announce); const bool call_audio_announce = isCallAudioAnnounce(announce); + const bool lxst_telephony_announce = + isLxstTelephonyAnnounce(announce); const bool nomad_node_announce = isNomadNetworkNodeAnnounce(announce); - LocalDestinationKind local_kind = LocalDestinationKind::Delivery; - const bool local_destination = isLocalDestinationHash(packet.destination_hash, &local_kind); char packet_hash_hex[(reticulum::kTruncatedHashSize * 2U) + 1U] = {}; char identity_hash_hex[(reticulum::kTruncatedHashSize * 2U) + 1U] = {}; @@ -2995,7 +3410,9 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len announce_display_name, sizeof(announce_display_name)); } - if ((delivery_announce || call_audio_announce) && announce_display_name[0] == '\0') + if ((delivery_announce || + (call_audio_announce && !lxst_telephony_announce)) && + announce_display_name[0] == '\0') { copyCString(announce_display_name, sizeof(announce_display_name), @@ -3139,6 +3556,13 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len PeerInfo& peer = upsertPeer(peer_destination_hash); const uint32_t previous_seen_s = peer.last_seen_s; const bool delivery_ratchet_available = delivery_announce && packet_has_ratchet; + const bool ratchet_changed = + delivery_announce && + (peerHasUsableRatchet(peer) != delivery_ratchet_available || + (delivery_ratchet_available && + std::memcmp(peer.ratchet_pub, + announce.ratchet, + sizeof(peer.ratchet_pub)) != 0)); const bool identity_changed = isZeroBytes(peer.identity_hash, sizeof(peer.identity_hash)) || !hashesEqual(peer.identity_hash, identity_hash, sizeof(peer.identity_hash)) || @@ -3183,6 +3607,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len const bool should_store_address = ingress_interface != reticulum::interfaces::InterfaceKind::WifiGateway || identity_changed || + ratchet_changed || display_changed || address_refresh_due; if (allow_persistence && should_store_address) @@ -3410,6 +3835,59 @@ bool LxmfAdapter::handleProofPacket( return maybeForwardLinkPacket(raw_packet, raw_len, packet); } + if (runtime::PendingPingReceipt* pending = + runtime::findPendingPingReceipt(transport_, packet.destination_hash)) + { + const uint8_t* signature = nullptr; + if (packet.payload_len == reticulum::kSignatureSize) + { + signature = packet.payload; + } + else if (packet.payload_len == + (reticulum::kFullHashSize + reticulum::kSignatureSize) && + packet.payload && + hashesEqual(packet.payload, + pending->packet_hash, + reticulum::kFullHashSize)) + { + signature = packet.payload + reticulum::kFullHashSize; + } + + const bool valid = + signature && + LxmfIdentity::verify(pending->peer_sig_pub, + signature, + pending->packet_hash, + sizeof(pending->packet_hash)); + char destination_hash[12] = {}; + formatHashPrefix(pending->destination_hash, + destination_hash, + sizeof(destination_hash)); + if (!valid) + { + Serial.printf("[LXMF][PingRX] reject reason=invalid_proof dest=%s payload_len=%u hops=%u\n", + destination_hash, + static_cast(packet.payload_len), + static_cast(packet.hops)); + return false; + } + + const uint32_t rtt_ms = millis() - pending->created_ms; + Serial.printf("[LXMF][PingRX] delivered dest=%s rtt_ms=%lu hops=%u\n", + destination_hash, + static_cast(rtt_ms), + static_cast(packet.hops)); + sys::EventBus::publish( + new sys::ReticulumPingResultEvent( + pending->destination_hash, + sys::ReticulumPingResult::Delivered, + rtt_ms, + packet.hops), + 100); + runtime::removePendingPingReceipt(transport_, packet.destination_hash); + return true; + } + ReverseEntry* reverse = findReversePath(packet.destination_hash); if (!reverse) { @@ -3488,12 +3966,44 @@ bool LxmfAdapter::handleLinkRequestPacket( return false; } + auto call_phase = ::platform::ui::reticulum_call::RealtimePhase::Idle; + uint8_t current_call_link_id[reticulum::kTruncatedHashSize] = {}; + bool current_call_link = false; + bool reject_busy_call = false; + if (local_kind == LocalDestinationKind::CallAudio) + { + call_phase = ::platform::ui::reticulum_call::realtime_phase(); + current_call_link = + ::platform::ui::reticulum_call::current_link_id(current_call_link_id) && + hashesEqual(current_call_link_id, link_id, sizeof(link_id)); + reject_busy_call = + call_phase != ::platform::ui::reticulum_call::RealtimePhase::Idle && + !current_call_link; + } + LinkSession* session = findLinkSession(link_id); if (!session) { if (links_.sessions.size() >= kMaxLinkSessions) { - links_.sessions.erase(links_.sessions.begin()); + auto discard = links_.sessions.begin(); + if (reject_busy_call) + { + discard = std::find_if( + links_.sessions.begin(), + links_.sessions.end(), + [¤t_call_link_id](const LinkSession& candidate) + { + return !hashesEqual(candidate.link_id, + current_call_link_id, + sizeof(candidate.link_id)); + }); + if (discard == links_.sessions.end()) + { + return false; + } + } + links_.sessions.erase(discard); } links_.sessions.push_back(LinkSession{}); @@ -3515,6 +4025,18 @@ bool LxmfAdapter::handleLinkRequestPacket( session->destination = local_kind; session->initiator = false; session->state = LinkState::Handshake; + if (local_kind == LocalDestinationKind::CallAudio) + { + session->call_wire_profile = + config_.reticulum_call_wire_profile; + session->lxst_profile = + reticulum::lxst::kProfileBandwidthLow; + session->lxst_local_status = + reticulum::lxst::kStatusCalling; + session->lxst_remote_status = + reticulum::lxst::kStatusCalling; + session->call_status_ms = session->created_ms; + } if (!deriveLinkKey(*session)) { @@ -3522,19 +4044,69 @@ bool LxmfAdapter::handleLinkRequestPacket( return false; } - if (local_kind == LocalDestinationKind::CallAudio) + if (reject_busy_call) { - ::platform::ui::reticulum_call::Peer call_peer{}; - copyHash(call_peer.link_id, session->link_id, sizeof(call_peer.link_id)); - call_peer.display_name = "Incoming call"; - call_peer.incoming = true; - const bool ui_started = - ::platform::ui::reticulum_call::begin_incoming(call_peer); + const bool proof_sent = sendLinkHandshakeProof(*session, true); + const bool busy_sent = + proof_sent && + (session->call_wire_profile != + ReticulumCallWireProfile::SidebandLxst || + sendLxstSignal(*session, + reticulum::lxst::kStatusBusy, + true)); + const bool close_sent = + busy_sent && + sendLinkPacket(*session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session->link_id, + sizeof(session->link_id), + true, + true); char link_hash[12] = {}; formatHashPrefix(session->link_id, link_hash, sizeof(link_hash)); - Serial.printf("[LXMF][CallRX] begin_incoming link=%s ui=%u iface=%u\n", + Serial.printf("[LXMF][CallRX] reject_busy link=%s wire=%u phase=%u proof=%u busy=%u close=%u iface=%u\n", link_hash, + static_cast(session->call_wire_profile), + static_cast(call_phase), + proof_sent ? 1U : 0U, + busy_sent ? 1U : 0U, + close_sent ? 1U : 0U, + static_cast(ingress_interface)); + links_.sessions.pop_back(); + return proof_sent && busy_sent && close_sent; + } + + if (local_kind == LocalDestinationKind::CallAudio) + { + char link_hash[12] = {}; + formatHashPrefix(session->link_id, link_hash, sizeof(link_hash)); + bool ui_started = false; + if (session->call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio) + { + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, + session->link_id, + sizeof(call_peer.link_id)); + call_peer.incoming = true; + call_peer.wire_profile = + callRuntimeWireProfile(session->call_wire_profile); + call_peer.codec2_mode = callRuntimeCodec2Mode( + session->call_wire_profile, + session->lxst_profile); + ui_started = + ::platform::ui::reticulum_call::begin_incoming(call_peer); + session->call_runtime_started = ui_started; + } + Serial.printf("[LXMF][CallRX] link_admitted link=%s wire=%u ui=%u await_identify=%u iface=%u\n", + link_hash, + static_cast(session->call_wire_profile), ui_started ? 1U : 0U, + session->call_wire_profile == + ReticulumCallWireProfile::SidebandLxst + ? 1U + : 0U, static_cast(ingress_interface)); } } @@ -3745,10 +4317,14 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, } else if (session.destination == LocalDestinationKind::CallAudio) { - handled = ::platform::ui::reticulum_call::enqueue_inbound_audio( - session.link_id, - payload_ptr, - payload_len); + handled = + session.call_wire_profile == + ReticulumCallWireProfile::SidebandLxst + ? handleLxstPacket(session, payload_ptr, payload_len) + : ::platform::ui::reticulum_call::enqueue_inbound_audio( + session.link_id, + payload_ptr, + payload_len); } else if (session.destination == LocalDestinationKind::Delivery) { @@ -3790,18 +4366,41 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, session.remote_identity_known = true; if (session.destination == LocalDestinationKind::CallAudio) { - ::platform::ui::reticulum_call::Peer call_peer{}; - copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); - copyHash(call_peer.destination_hash, - peer->destination_hash, - sizeof(call_peer.destination_hash)); - copyHash(call_peer.identity_hash, - peer->identity_hash, - sizeof(call_peer.identity_hash)); - call_peer.display_name = - peer->display_name[0] != '\0' ? peer->display_name : nullptr; - call_peer.incoming = !session.initiator; - ::platform::ui::reticulum_call::update_peer(call_peer); + updateCallRuntimePeer(session, peer); + if (!session.initiator && + session.call_wire_profile == + ReticulumCallWireProfile::SidebandLxst && + !session.call_runtime_started) + { + const bool ringing_started = + beginIncomingCallRuntime(session, *peer); + const bool ringing_sent = + ringing_started && + sendLxstSignal( + session, + reticulum::lxst::kStatusRinging, + true); + if (!ringing_sent) + { + if (!ringing_started) + { + (void)sendLxstSignal( + session, + reticulum::lxst::kStatusBusy, + true); + } + (void)sendLinkPacket( + session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session.link_id, + sizeof(session.link_id), + true, + true); + closeLinkSession(session, + LinkCloseReason::LocalClose); + } + } } } handled = true; @@ -3871,10 +4470,25 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, session.state = LinkState::Active; if (session.destination == LocalDestinationKind::CallAudio) { - (void)sendLinkIdentify(session); - ::platform::ui::reticulum_call::mark_link_active(session.link_id); + if (session.call_wire_profile == + ReticulumCallWireProfile::SidebandLxst) + { + handled = sendLxstSignal( + session, + reticulum::lxst::kStatusAvailable, + true); + } + else + { + (void)sendLinkIdentify(session); + ::platform::ui::reticulum_call::mark_link_active( + session.link_id); + } + } + else + { + handled = true; } - handled = true; } } else if (context == static_cast(reticulum::PacketContext::LinkClose)) @@ -4220,23 +4834,18 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, } if (session.destination == LocalDestinationKind::CallAudio) { - (void)sendLinkIdentify(session); - ::platform::ui::reticulum_call::Peer call_peer{}; - copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); - copyHash(call_peer.identity_hash, - session.remote_identity_hash, - sizeof(call_peer.identity_hash)); - if (peer) + updateCallRuntimePeer(session, peer); + if (session.call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio) { - copyHash(call_peer.destination_hash, - peer->destination_hash, - sizeof(call_peer.destination_hash)); - call_peer.display_name = - peer->display_name[0] != '\0' ? peer->display_name : nullptr; + (void)sendLinkIdentify(session); + ::platform::ui::reticulum_call::mark_link_active( + session.link_id); + } + else + { + session.call_status_ms = millis(); } - call_peer.incoming = false; - ::platform::ui::reticulum_call::update_peer(call_peer); - ::platform::ui::reticulum_call::mark_link_active(session.link_id); } flushDeferredLinkPayloads(session); return rtt_sent; @@ -4857,6 +5466,39 @@ bool LxmfAdapter::handleLinkResourcePart(LinkSession& session, memcpy(proof_payload.data() + reticulum::kFullHashSize, resource.expected_proof, reticulum::kFullHashSize); + + const bool is_request = (resource.flags & kResourceFlagRequest) != 0; + const bool is_response = (resource.flags & kResourceFlagResponse) != 0; + const bool single_segment = + !resource.split && resource.total_segments <= 1U; + bool delivery_preaccepted = false; + if (single_segment && !is_request && !is_response && + session.destination == LocalDestinationKind::Delivery) + { + delivery_preaccepted = + acceptVerifiedEnvelope(payload_data.data(), + payload_data.size(), + nullptr, + 0); + if (!delivery_preaccepted) + { + uint8_t rejected_resource_hash[reticulum::kFullHashSize] = {}; + copyHash(rejected_resource_hash, + resource.resource_hash, + sizeof(rejected_resource_hash)); + char resource_prefix[9] = {}; + formatHashPrefix(rejected_resource_hash, + resource_prefix, + sizeof(resource_prefix)); + (void)runtime::eraseLinkResourceByHash( + session.incoming_resources, + rejected_resource_hash); + Serial.printf("[LXMF][ResourceRX] delivery_rejected resource=%s reason=queue_or_envelope\n", + resource_prefix); + return false; + } + } + (void)sendLinkPacket(session, reticulum::PacketType::Proof, reticulum::PacketContext::ResourcePrf, @@ -4877,8 +5519,6 @@ bool LxmfAdapter::handleLinkResourcePart(LinkSession& session, return true; } - const bool is_request = (resource.flags & kResourceFlagRequest) != 0; - const bool is_response = (resource.flags & kResourceFlagResponse) != 0; if (is_request) { DecodedLinkRequest request{}; @@ -4923,7 +5563,8 @@ bool LxmfAdapter::handleLinkResourcePart(LinkSession& session, } } } - else if (session.destination == LocalDestinationKind::Delivery) + else if (session.destination == LocalDestinationKind::Delivery && + !delivery_preaccepted) { (void)acceptVerifiedEnvelope(payload_data.data(), payload_data.size(), nullptr, 0); } @@ -5670,6 +6311,11 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) session.remote_identity_hash, sizeof(call_peer.identity_hash)); call_peer.incoming = false; + call_peer.wire_profile = + callRuntimeWireProfile(session.call_wire_profile); + call_peer.codec2_mode = callRuntimeCodec2Mode( + session.call_wire_profile, + session.lxst_profile); if (!::platform::ui::reticulum_call::begin_outgoing(call_peer)) { Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=call_realtime_busy\n", @@ -5677,6 +6323,7 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) static_cast(session.destination)); return false; } + session.call_runtime_started = true; } const bool sent = wifi_only ? interfaces_.sendPacketWifiOnly(tx_packet, tx_packet_len, @@ -6071,7 +6718,8 @@ bool LxmfAdapter::shouldProcessWifiIngressPacket(const reticulum::ParsedPacket& } if (packet.packet_type == reticulum::PacketType::Proof && - findReversePath(packet.destination_hash)) + (findReversePath(packet.destination_hash) || + runtime::findPendingPingReceipt(transport_, packet.destination_hash))) { return true; } @@ -6394,6 +7042,29 @@ void LxmfAdapter::resolvePendingPathRequest( void LxmfAdapter::cullTransportState() { + const uint32_t now_ms = millis(); + for (const auto& receipt : transport_.pending_ping_receipts) + { + if (receipt.created_ms == 0 || + (now_ms - receipt.created_ms) <= kPendingPingReceiptTtlMs) + { + continue; + } + char destination_hash[12] = {}; + formatHashPrefix(receipt.destination_hash, + destination_hash, + sizeof(destination_hash)); + Serial.printf("[LXMF][PingRX] timeout dest=%s elapsed_ms=%lu\n", + destination_hash, + static_cast(now_ms - receipt.created_ms)); + sys::EventBus::publish( + new sys::ReticulumPingResultEvent( + receipt.destination_hash, + sys::ReticulumPingResult::Timeout, + now_ms - receipt.created_ms), + 100); + } + const runtime::TransportRuntimeLimits limits{ kMaxPaths, kMaxPacketFilter, @@ -6403,8 +7074,11 @@ void LxmfAdapter::cullTransportState() kPacketFilterTtlMs, kPendingPathRequestTtlMs, kReverseEntryTtlMs, - kLinkRelayTtlMs}; - runtime::cullTransportRuntime(transport_, millis(), limits); + kLinkRelayTtlMs, + kMaxPendingPingReceipts, + kPathTtlMs, + kPendingPingReceiptTtlMs}; + runtime::cullTransportRuntime(transport_, now_ms, limits); cullLinkSessions(); } @@ -6417,7 +7091,10 @@ LxmfAdapter::PathEntry& LxmfAdapter::upsertPath( const LxmfAdapter::PathEntry* LxmfAdapter::findPath( const uint8_t destination_hash[reticulum::kTruncatedHashSize]) const { - return runtime::findPath(transport_, destination_hash); + const PathEntry* path = runtime::findPath(transport_, destination_hash); + return path && !runtime::pathExpired(*path, millis(), kPathTtlMs) + ? path + : nullptr; } LxmfAdapter::LinkRelayEntry& LxmfAdapter::upsertLinkRelay( @@ -6447,7 +7124,15 @@ void LxmfAdapter::localDestinationHash(LocalDestinationKind kind, } else if (kind == LocalDestinationKind::CallAudio) { - reticulum::computeNameHash("call", "audio", name_hash); + if (config_.reticulum_call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio) + { + reticulum::computeNameHash("call", "audio", name_hash); + } + else + { + reticulum::computeNameHash("lxst", "telephony", name_hash); + } } else { @@ -6620,7 +7305,8 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, reticulum::PacketType packet_type, reticulum::PacketContext context, const uint8_t* payload, size_t payload_len, - bool encrypt_payload) + bool encrypt_payload, + bool call_admission_control) { if (!isReady() || (!payload && payload_len != 0)) { @@ -6658,7 +7344,8 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, const bool ok = session.destination == LocalDestinationKind::CallAudio ? interfaces_.sendPacketWifiOnly(packet, packet_len, - session.link_id) + session.link_id, + call_admission_control) : interfaces_.sendPacket(packet, packet_len); if (ok) { @@ -7171,7 +7858,8 @@ void LxmfAdapter::pumpNomadPageRequests() } } -bool LxmfAdapter::sendLinkHandshakeProof(LinkSession& session) +bool LxmfAdapter::sendLinkHandshakeProof(LinkSession& session, + bool call_admission_control) { uint8_t signalling[kLinkSignallingLen] = {}; buildLinkSignallingBytes(session.mtu, signalling); @@ -7209,7 +7897,8 @@ bool LxmfAdapter::sendLinkHandshakeProof(LinkSession& session) reticulum::PacketContext::LrProof, proof_payload, sizeof(proof_payload), - false); + false, + call_admission_control); } bool LxmfAdapter::sendLinkRtt(LinkSession& session) @@ -7257,7 +7946,8 @@ bool LxmfAdapter::sendLinkKeepaliveAck(LinkSession& session) bool LxmfAdapter::sendLinkIdentify(LinkSession& session) { - if (!identity_.isReady() || session.state != LinkState::Active) + if (!identity_.isReady() || !session.initiator || + session.state != LinkState::Active || session.local_identity_sent) { return false; } @@ -7281,12 +7971,17 @@ bool LxmfAdapter::sendLinkIdentify(LinkSession& session) return false; } - return sendLinkPacket(session, - reticulum::PacketType::Data, - reticulum::PacketContext::LinkIdentify, - payload, - sizeof(payload), - true); + const bool sent = sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkIdentify, + payload, + sizeof(payload), + true); + if (sent) + { + session.local_identity_sent = true; + } + return sent; } bool LxmfAdapter::sendLinkPacketProof(LinkSession& session, @@ -7557,6 +8252,289 @@ bool LxmfAdapter::queueOutgoingResource(LinkSession& session, return true; } +void LxmfAdapter::updateCallRuntimePeer(LinkSession& session, + const PeerInfo* peer) +{ + if (session.destination != LocalDestinationKind::CallAudio) + { + return; + } + + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); + copyHash(call_peer.identity_hash, + session.remote_identity_hash, + sizeof(call_peer.identity_hash)); + if (peer) + { + copyHash(call_peer.destination_hash, + peer->destination_hash, + sizeof(call_peer.destination_hash)); + call_peer.display_name = + peer->display_name[0] != '\0' ? peer->display_name : nullptr; + } + else + { + copyHash(call_peer.destination_hash, + session.remote_destination_hash, + sizeof(call_peer.destination_hash)); + } + call_peer.incoming = !session.initiator; + call_peer.wire_profile = + callRuntimeWireProfile(session.call_wire_profile); + call_peer.codec2_mode = callRuntimeCodec2Mode( + session.call_wire_profile, + session.lxst_profile); + ::platform::ui::reticulum_call::update_peer(call_peer); +} + +bool LxmfAdapter::beginIncomingCallRuntime(LinkSession& session, + const PeerInfo& peer) +{ + if (session.destination != LocalDestinationKind::CallAudio || + session.initiator || + !session.remote_identity_known) + { + return false; + } + if (session.call_runtime_started) + { + return true; + } + + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); + copyHash(call_peer.destination_hash, + peer.destination_hash, + sizeof(call_peer.destination_hash)); + copyHash(call_peer.identity_hash, + peer.identity_hash, + sizeof(call_peer.identity_hash)); + call_peer.display_name = + peer.display_name[0] != '\0' ? peer.display_name : nullptr; + call_peer.incoming = true; + call_peer.wire_profile = + callRuntimeWireProfile(session.call_wire_profile); + call_peer.codec2_mode = callRuntimeCodec2Mode( + session.call_wire_profile, + session.lxst_profile); + session.call_runtime_started = + ::platform::ui::reticulum_call::begin_incoming(call_peer); + + char link_hash[12] = {}; + formatHashPrefix(session.link_id, link_hash, sizeof(link_hash)); + Serial.printf("[LXMF][CallRX] identified link=%s wire=%u runtime=%u peer=%s\n", + link_hash, + static_cast(session.call_wire_profile), + session.call_runtime_started ? 1U : 0U, + call_peer.display_name ? call_peer.display_name : ""); + return session.call_runtime_started; +} + +bool LxmfAdapter::sendLxstSignal(LinkSession& session, + uint16_t signal, + bool call_admission_control) +{ + if (session.destination != LocalDestinationKind::CallAudio || + session.call_wire_profile != ReticulumCallWireProfile::SidebandLxst) + { + return false; + } + + size_t payload_len = sizeof(call_wire_scratch_); + if (!reticulum::lxst::encodeSignalling(signal, + call_wire_scratch_, + &payload_len)) + { + return false; + } + const bool sent = sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + call_wire_scratch_, + payload_len, + true, + call_admission_control); + if (sent) + { + if (signal <= reticulum::lxst::kStatusEstablished) + { + session.lxst_local_status = signal; + session.call_status_ms = millis(); + } + if (signal >= reticulum::lxst::kPreferredProfile) + { + session.lxst_preferred_sent = true; + } + } + + char link_hash[12] = {}; + formatHashPrefix(session.link_id, link_hash, sizeof(link_hash)); + Serial.printf("[LXMF][CallTX] lxst_signal link=%s signal=%u ok=%u\n", + link_hash, + static_cast(signal), + sent ? 1U : 0U); + return sent; +} + +bool LxmfAdapter::handleLxstPacket(LinkSession& session, + const uint8_t* payload, + size_t payload_len) +{ + reticulum::lxst::DecodedPacket decoded{}; + if (!reticulum::lxst::decodePacket(payload, payload_len, &decoded)) + { + return false; + } + + bool handled = false; + bool terminate = false; + for (size_t index = 0; index < decoded.signal_count; ++index) + { + const uint16_t signal = decoded.signals[index]; + handled = true; + if (signal >= reticulum::lxst::kPreferredProfile) + { + const uint16_t requested_profile = + signal - reticulum::lxst::kPreferredProfile; + reticulum::audio_call::Codec2Mode requested_mode{}; + const bool supported = reticulum::lxst::profileToCodec2Mode( + requested_profile, + &requested_mode); + const auto call_snapshot = + ::platform::ui::reticulum_call::snapshot(); + if (supported && !call_snapshot.media_active) + { + session.lxst_profile = requested_profile; + updateCallRuntimePeer( + session, + findPeerByIdentityHash(session.remote_identity_hash)); + } + else if (!supported || + requested_profile != session.lxst_profile) + { + (void)sendLxstSignal( + session, + reticulum::lxst::kPreferredProfile + + session.lxst_profile, + true); + } + continue; + } + + session.lxst_remote_status = signal; + session.call_status_ms = millis(); + if (signal == reticulum::lxst::kStatusBusy || + signal == reticulum::lxst::kStatusRejected) + { + terminate = true; + } + else if (signal == reticulum::lxst::kStatusAvailable && + session.initiator) + { + (void)sendLinkIdentify(session); + } + else if (signal == reticulum::lxst::kStatusRinging && + session.initiator) + { + (void)sendLxstSignal( + session, + reticulum::lxst::kPreferredProfile + + session.lxst_profile, + true); + } + else if ((signal == reticulum::lxst::kStatusConnecting || + signal == reticulum::lxst::kStatusEstablished) && + session.initiator) + { + ::platform::ui::reticulum_call::mark_link_active( + session.link_id); + } + } + + const reticulum::audio_call::Codec2Mode expected_mode = + callAudioCodec2Mode(session.lxst_profile); + for (size_t index = 0; index < decoded.frame_count; ++index) + { + const auto& frame = decoded.frames[index]; + if (frame.codec != reticulum::lxst::kCodec2 || + !frame.codec2_mode_valid || + frame.codec2_mode != expected_mode || + !frame.encoded || frame.encoded_len == 0) + { + continue; + } + + size_t normalized_len = sizeof(call_wire_scratch_); + if (reticulum::audio_call::encodePayload(frame.codec2_mode, + frame.encoded, + frame.encoded_len, + call_wire_scratch_, + &normalized_len) && + ::platform::ui::reticulum_call::enqueue_inbound_audio( + session.link_id, + call_wire_scratch_, + normalized_len)) + { + handled = true; + } + } + + if (terminate) + { + (void)sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session.link_id, + sizeof(session.link_id), + true, + true); + closeLinkSession(session, LinkCloseReason::RemoteClose); + } + return handled; +} + +bool LxmfAdapter::sendCallAudioPacket(LinkSession& session, + const uint8_t* payload, + size_t payload_len) +{ + if (session.call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio) + { + return sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + payload, + payload_len, + true); + } + + reticulum::audio_call::DecodedPayload decoded{}; + if (!reticulum::audio_call::decodePayload(payload, + payload_len, + &decoded) || + decoded.mode != callAudioCodec2Mode(session.lxst_profile)) + { + return false; + } + + size_t lxst_len = sizeof(call_wire_scratch_); + if (!reticulum::lxst::encodeCodec2Frames(decoded.mode, + decoded.encoded, + decoded.encoded_len, + call_wire_scratch_, + &lxst_len)) + { + return false; + } + return sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + call_wire_scratch_, + lxst_len, + true); +} + void LxmfAdapter::pumpReticulumAudioCall() { ::platform::ui::reticulum_call::set_wifi_ready( @@ -7569,11 +8547,23 @@ void LxmfAdapter::pumpReticulumAudioCall() { if (session->destination == LocalDestinationKind::CallAudio) { + if (session->call_wire_profile == + ReticulumCallWireProfile::SidebandLxst && + !session->initiator && + session->lxst_local_status == + reticulum::lxst::kStatusRinging) + { + (void)sendLxstSignal( + *session, + reticulum::lxst::kStatusRejected, + true); + } (void)sendLinkPacket(*session, reticulum::PacketType::Data, reticulum::PacketContext::LinkClose, session->link_id, sizeof(session->link_id), + true, true); closeLinkSession(*session, LinkCloseReason::LocalClose); } @@ -7588,6 +8578,52 @@ void LxmfAdapter::pumpReticulumAudioCall() } } + auto call_snapshot = ::platform::ui::reticulum_call::snapshot(); + if (call_snapshot.wire_profile == + ::platform::ui::reticulum_call::WireProfile::SidebandLxst && + call_snapshot.realtime_phase != + ::platform::ui::reticulum_call::RealtimePhase::ClosingCall) + { + LinkSession* session = findLinkSession(call_snapshot.link_id); + if (session && + session->destination == LocalDestinationKind::CallAudio && + session->state == LinkState::Active && + session->call_wire_profile == + ReticulumCallWireProfile::SidebandLxst) + { + if (!session->initiator && + call_snapshot.accepted && + session->lxst_local_status == + reticulum::lxst::kStatusRinging && + sendLxstSignal(*session, + reticulum::lxst::kStatusConnecting, + true)) + { + ::platform::ui::reticulum_call::mark_link_active( + session->link_id); + call_snapshot = + ::platform::ui::reticulum_call::snapshot(); + } + + const bool setup_started = + session->lxst_local_status == + reticulum::lxst::kStatusConnecting || + (session->initiator && + session->lxst_remote_status >= + reticulum::lxst::kStatusConnecting); + if (call_snapshot.media_active && + setup_started && + session->lxst_local_status != + reticulum::lxst::kStatusEstablished) + { + (void)sendLxstSignal( + *session, + reticulum::lxst::kStatusEstablished, + true); + } + } + } + uint8_t sent_packets = 0; ::platform::ui::reticulum_call::AudioPacket audio{}; while (sent_packets < 2 && @@ -7601,12 +8637,9 @@ void LxmfAdapter::pumpReticulumAudioCall() continue; } - if (sendLinkPacket(*session, - reticulum::PacketType::Data, - reticulum::PacketContext::None, - audio.data, - audio.len, - true)) + if (sendCallAudioPacket(*session, + audio.data, + audio.len)) { ::platform::ui::reticulum_call::note_tx_sent(); ++sent_packets; @@ -7701,6 +8734,11 @@ void LxmfAdapter::flushDeferredLinkPayloads(LinkSession& session) break; } + if (session.destination == LocalDestinationKind::Delivery) + { + (void)sendLinkIdentify(session); + } + if (deferred.message_id != 0) { Serial.printf("[LXMF][DirectTX] deferred_sent msg=%lu path=link payload_len=%u\n", @@ -7751,6 +8789,8 @@ LxmfAdapter::LinkSession* LxmfAdapter::findActiveLinkSessionByDestination( void LxmfAdapter::cullLinkSessions() { const uint32_t now_ms = millis(); + const auto call_snapshot = + ::platform::ui::reticulum_call::snapshot(); const runtime::LinkRuntimeLimits limits{ kMaxLinkSessions, kLinkRequestTtlMs, @@ -7764,6 +8804,85 @@ void LxmfAdapter::cullLinkSessions() for (auto& session : links_.sessions) { + uint32_t call_timeout_ms = 0; + if (session.destination == LocalDestinationKind::CallAudio && + session.state == LinkState::Active && + session.call_status_ms != 0) + { + if (session.call_wire_profile == + ReticulumCallWireProfile::SidebandLxst) + { + if (session.initiator) + { + if (session.lxst_remote_status < + reticulum::lxst::kStatusRinging) + { + call_timeout_ms = kCallIdentifyTimeoutMs; + } + else if (session.lxst_remote_status == + reticulum::lxst::kStatusRinging) + { + call_timeout_ms = kCallDialTimeoutMs; + } + else if (session.lxst_remote_status == + reticulum::lxst::kStatusConnecting) + { + call_timeout_ms = kCallMediaSetupTimeoutMs; + } + } + else if (session.lxst_local_status == + reticulum::lxst::kStatusAvailable) + { + call_timeout_ms = kCallIdentifyTimeoutMs; + } + else if (session.lxst_local_status == + reticulum::lxst::kStatusRinging) + { + call_timeout_ms = kCallRingTimeoutMs; + } + else if (session.lxst_local_status == + reticulum::lxst::kStatusConnecting) + { + call_timeout_ms = kCallMediaSetupTimeoutMs; + } + } + else if (!session.initiator && + hashesEqual(call_snapshot.link_id, + session.link_id, + sizeof(session.link_id)) && + call_snapshot.realtime_phase == + ::platform::ui::reticulum_call:: + RealtimePhase::IncomingRinging) + { + call_timeout_ms = kCallRingTimeoutMs; + } + } + + if (call_timeout_ms != 0 && + now_ms - session.call_status_ms >= call_timeout_ms) + { + char link_hash[12] = {}; + formatHashPrefix(session.link_id, + link_hash, + sizeof(link_hash)); + Serial.printf("[LXMF][Call] phase_timeout link=%s wire=%u local=%u remote=%u elapsed_ms=%lu\n", + link_hash, + static_cast(session.call_wire_profile), + static_cast(session.lxst_local_status), + static_cast(session.lxst_remote_status), + static_cast( + now_ms - session.call_status_ms)); + (void)sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session.link_id, + sizeof(session.link_id), + true, + true); + closeLinkSession(session, LinkCloseReason::Timeout); + continue; + } + runtime::cullLinkSessionTables(session, now_ms, limits); runtime::cullLinkResources(session, now_ms, resource_limits); const runtime::LinkRuntimeMaintenance maintenance = @@ -7855,8 +8974,6 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( const uint8_t* plaintext, size_t plaintext_len, const uint8_t* raw_packet, size_t raw_len) { - (void)raw_packet; - char expected_hash[12] = {}; formatHashPrefix(expected_destination_hash, expected_hash, sizeof(expected_hash)); Serial.printf("[LXMF][%sRX] envelope begin dest=%s plaintext_len=%u raw_len=%u encrypted=%u\n", @@ -7873,6 +8990,17 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( return false; } + const auto release_packet_for_retry = [this, raw_packet, raw_len]() + { + if (!raw_packet || raw_len == 0) + { + return; + } + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + reticulum::computePacketHash(raw_packet, raw_len, packet_hash); + runtime::forgetPacket(transport_, packet_hash); + }; + DecodedEnvelope envelope{}; bool embedded_destination_hash = false; if (!unpackRnsLxmfEnvelope(expected_destination_hash, @@ -7912,16 +9040,6 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( return false; } - const PeerInfo* peer = findPeerByDestinationHash(envelope.source_hash); - if (!peer) - { - Serial.printf("[LXMF][%sRX] drop reason=unknown_sender dest=%s source=%s\n", - destination_is_group ? "Group" : "Direct", - expected_hash, - source_hash); - return false; - } - const size_t signed_part_required = (reticulum::kTruncatedHashSize * 2) + envelope.packed_payload.size() + @@ -7945,54 +9063,113 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( return false; } - if (!LxmfIdentity::verify(peer->sig_pub, - envelope.signature, - signed_part.data(), - signed_part_len)) - { - Serial.printf("[LXMF][%sRX] drop reason=signature_failed dest=%s source=%s node=%08lX\n", - destination_is_group ? "Group" : "Direct", - expected_hash, - source_hash, - static_cast(peer->node_id)); - return false; - } - + PeerInfo* peer = findOrLoadPeerByDestinationHash(envelope.source_hash); runtime::LxmfDeliveryContext delivery_context{}; - delivery_context.peer_node_id = peer->node_id; delivery_context.local_node_id = identity_.nodeId(); delivery_context.message_id = messageIdFromHash(message_hash); delivery_context.timestamp_s = currentTimestampSeconds(); - delivery_context.peer_identity = reticulumIdentityForPeer(*peer); - delivery_context.conversation_identity = - hasReticulumDestinationIdentity(conversation_identity) - ? conversation_identity - : delivery_context.peer_identity; delivery_context.destination_is_group = destination_is_group; delivery_context.encrypted = encrypted; populateRxMeta(&delivery_context.rx_meta); runtime::LxmfVerifiedDelivery delivery{}; - if (!runtime::materialiseVerifiedLxmfDelivery(envelope.packed_payload.data(), - envelope.packed_payload.size(), - delivery_context, - &delivery)) + if (!peer) { - Serial.printf("[LXMF][%sRX] drop reason=materialise_failed dest=%s source=%s msg=%lu payload_len=%u\n", + const bool path_requested = + sendPathRequestForDestination(envelope.source_hash); + delivery_context.peer_node_id = + reticulum::nodeIdFromDestinationHash(envelope.source_hash); + delivery_context.peer_identity = + makeReticulumDestinationIdentity(envelope.source_hash); + delivery_context.conversation_identity = + hasReticulumDestinationIdentity(conversation_identity) + ? conversation_identity + : delivery_context.peer_identity; + delivery_context.source_unverified = true; + + DecodedAppData unverified_app_data{}; + if (decodeAppDataPayload(envelope.packed_payload.data(), + envelope.packed_payload.size(), + &unverified_app_data)) + { + Serial.printf("[LXMF][%sRX] unverified ignored kind=app_data dest=%s source=%s msg=%lu port=%lu path_requested=%u transport_ack=1\n", + destination_is_group ? "Group" : "Direct", + expected_hash, + source_hash, + static_cast(delivery_context.message_id), + static_cast(unverified_app_data.portnum), + path_requested ? 1U : 0U); + return true; + } + + DecodedTextPayload text_payload{}; + if (!unpackTextPayload(envelope.packed_payload.data(), + envelope.packed_payload.size(), + &text_payload) || + !runtime::materialiseLxmfTextDelivery(text_payload, + delivery_context, + &delivery.text)) + { + Serial.printf("[LXMF][%sRX] unverified ignored kind=malformed dest=%s source=%s msg=%lu payload_len=%u path_requested=%u transport_ack=1\n", + destination_is_group ? "Group" : "Direct", + expected_hash, + source_hash, + static_cast(delivery_context.message_id), + static_cast(envelope.packed_payload.size()), + path_requested ? 1U : 0U); + return true; + } + delivery.kind = runtime::LxmfDeliveryKind::Text; + Serial.printf("[LXMF][%sRX] unverified kind=text msg=%lu from=%08lX dest=%s source=%s path_requested=%u\n", destination_is_group ? "Group" : "Direct", + static_cast(delivery_context.message_id), + static_cast(delivery_context.peer_node_id), expected_hash, source_hash, - static_cast(delivery_context.message_id), - static_cast(envelope.packed_payload.size())); - return false; + path_requested ? 1U : 0U); + } + else + { + if (!LxmfIdentity::verify(peer->sig_pub, + envelope.signature, + signed_part.data(), + signed_part_len)) + { + Serial.printf("[LXMF][%sRX] ignored reason=signature_failed dest=%s source=%s node=%08lX transport_ack=1\n", + destination_is_group ? "Group" : "Direct", + expected_hash, + source_hash, + static_cast(peer->node_id)); + return true; + } + + delivery_context.peer_node_id = peer->node_id; + delivery_context.peer_identity = reticulumIdentityForPeer(*peer); + delivery_context.conversation_identity = + hasReticulumDestinationIdentity(conversation_identity) + ? conversation_identity + : delivery_context.peer_identity; + if (!runtime::materialiseVerifiedLxmfDelivery(envelope.packed_payload.data(), + envelope.packed_payload.size(), + delivery_context, + &delivery)) + { + Serial.printf("[LXMF][%sRX] ignored reason=materialise_failed dest=%s source=%s msg=%lu payload_len=%u transport_ack=1\n", + destination_is_group ? "Group" : "Direct", + expected_hash, + source_hash, + static_cast(delivery_context.message_id), + static_cast(envelope.packed_payload.size())); + return true; + } + Serial.printf("[LXMF][%sRX] verified kind=%u msg=%lu from=%08lX dest=%s source=%s\n", + destination_is_group ? "Group" : "Direct", + static_cast(delivery.kind), + static_cast(delivery_context.message_id), + static_cast(peer->node_id), + expected_hash, + source_hash); } - Serial.printf("[LXMF][%sRX] verified kind=%u msg=%lu from=%08lX dest=%s source=%s\n", - destination_is_group ? "Group" : "Direct", - static_cast(delivery.kind), - static_cast(delivery_context.message_id), - static_cast(peer->node_id), - expected_hash, - source_hash); if (delivery.kind == runtime::LxmfDeliveryKind::AppData) { @@ -8001,7 +9178,7 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( delivery.app_data.payload.empty() ? nullptr : delivery.app_data.payload.data(), delivery.app_data.payload.size(), - ::chat::infra::IncomingQueuePriority::P1User, + ::chat::infra::IncomingQueuePriority::P0Critical, &report)) { if (report.dropped_existing) @@ -8022,7 +9199,11 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( static_cast(delivery.app_data.incoming.portnum), static_cast(delivery.app_data.payload.size()), static_cast(data_receive_queue_.size())); - return true; + if (delivery.app_data.payload.size() <= ::chat::infra::kIncomingDataPayloadMaxLen) + { + release_packet_for_retry(); + } + return false; } if (delivery.kind != runtime::LxmfDeliveryKind::Text) @@ -8033,11 +9214,71 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( static_cast(delivery_context.message_id)); return false; } + + if (!delivery_context.source_unverified) + { + SidebandTelemetryLocation location{}; + if (decodeSidebandTelemetryLocation(delivery.text.payload, &location)) + { + const int32_t altitude_m = location.altitude_cm >= 0 + ? (location.altitude_cm + 50) / 100 + : (location.altitude_cm - 50) / 100; + sys::EventBus::publish( + new sys::NodePositionUpdateEvent( + delivery_context.peer_node_id, + location.latitude_e6 * 10, + location.longitude_e6 * 10, + true, + altitude_m, + location.timestamp != 0 ? location.timestamp + : delivery_context.timestamp_s, + 32, + 0, + 0, + 0, + location.accuracy_cm * 10), + 0); + Serial.printf("[LXMF][Sideband] telemetry location node=%08lX lat_e6=%ld lon_e6=%ld alt_cm=%ld accuracy_cm=%lu ts=%lu\n", + static_cast(delivery_context.peer_node_id), + static_cast(location.latitude_e6), + static_cast(location.longitude_e6), + static_cast(location.altitude_cm), + static_cast(location.accuracy_cm), + static_cast(location.timestamp)); + } + + SidebandTelemetryRequest telemetry_request{}; + if (decodeSidebandTelemetryRequest(delivery.text.payload, + &telemetry_request)) + { + if (!config_.reticulum_allow_location_requests) + { + Serial.printf("[LXMF][Sideband] telemetry request denied node=%08lX reason=disabled\n", + static_cast(delivery_context.peer_node_id)); + } + else if (peer) + { + (void)respondToSidebandTelemetryRequest(*peer, + telemetry_request); + } + } + } + + if (delivery.text.text.empty() && !delivery.text.payload.fields_empty) + { + Serial.printf("[LXMF][%sRX] accepted fields_only msg=%lu from=%08lX fields=%u unverified=%u\n", + destination_is_group ? "Group" : "Direct", + static_cast(delivery_context.message_id), + static_cast(delivery_context.peer_node_id), + static_cast(delivery.text.payload.fields.size()), + delivery_context.source_unverified ? 1U : 0U); + return true; + } ::chat::infra::IncomingQueuePushReport report{}; if (text_receive_queue_.push(delivery.text.incoming, delivery.text.text.data(), delivery.text.text.size(), - ::chat::infra::IncomingQueuePriority::P1User, + ::chat::infra::IncomingQueuePriority::P0Critical, &report)) { if (report.dropped_existing) @@ -8046,20 +9287,25 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( static_cast(report.dropped_priority), static_cast(text_receive_queue_.size())); } - Serial.printf("[LXMF][%sRX] queued text msg=%lu from=%08lX to=%08lX len=%u depth=%u dest=%s\n", + Serial.printf("[LXMF][%sRX] queued text msg=%lu from=%08lX to=%08lX len=%u depth=%u dest=%s unverified=%u\n", destination_is_group ? "Group" : "Direct", static_cast(delivery_context.message_id), static_cast(delivery.text.incoming.from), static_cast(delivery.text.incoming.to), static_cast(delivery.text.text.size()), static_cast(text_receive_queue_.size()), - expected_hash); + expected_hash, + delivery.text.incoming.source_unverified ? 1U : 0U); return true; } Serial.printf("[LXMF] RX text queue drop len=%u depth=%u\n", static_cast(delivery.text.text.size()), static_cast(text_receive_queue_.size())); - return true; + if (delivery.text.text.size() <= ::chat::infra::kIncomingTextMaxLen) + { + release_packet_for_retry(); + } + return false; } LxmfAdapter::PeerInfo* LxmfAdapter::findPeerByNodeId(NodeId node_id) @@ -8185,6 +9431,22 @@ LxmfAdapter::PeerInfo* LxmfAdapter::upsertPeerFromDirectoryRecord( copyHash(peer.identity_hash, identity.identity_hash, sizeof(peer.identity_hash)); memcpy(peer.enc_pub, record.reticulum.enc_pub, sizeof(peer.enc_pub)); memcpy(peer.sig_pub, record.reticulum.sig_pub, sizeof(peer.sig_pub)); + if (record.reticulum.has_ratchet && + !isZeroBytes(record.reticulum.ratchet_pub, + sizeof(record.reticulum.ratchet_pub))) + { + memcpy(peer.ratchet_pub, + record.reticulum.ratchet_pub, + sizeof(peer.ratchet_pub)); + peer.has_ratchet = true; + peer.ratchet_seen_s = record.reticulum.ratchet_seen_s; + } + else + { + memset(peer.ratchet_pub, 0, sizeof(peer.ratchet_pub)); + peer.has_ratchet = false; + peer.ratchet_seen_s = 0; + } peer.last_seen_s = record.last_seen_s != 0 ? record.last_seen_s : currentTimestampSeconds(); @@ -8334,6 +9596,14 @@ bool LxmfAdapter::recordPeerInDirectory(const PeerInfo& peer, memcpy(record.reticulum.sig_pub, peer.sig_pub, sizeof(record.reticulum.sig_pub)); + if (peerHasUsableRatchet(peer)) + { + record.reticulum.has_ratchet = true; + memcpy(record.reticulum.ratchet_pub, + peer.ratchet_pub, + sizeof(record.reticulum.ratchet_pub)); + record.reticulum.ratchet_seen_s = peer.ratchet_seen_s; + } record.reticulum.delivery = true; MeshPeerUserFlags flags{}; diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp index 056fd0c2..a46583cd 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp @@ -1,6 +1,6 @@ /** * @file lxmf_delivery_runtime.cpp - * @brief Product ingress materialisation for verified LXMF deliveries. + * @brief Product ingress materialisation for authenticated and source-unknown LXMF deliveries. */ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h" @@ -31,9 +31,11 @@ bool materialiseLxmfTextDelivery(const DecodedTextPayload& payload, hasReticulumDestinationIdentity(context.conversation_identity) ? context.conversation_identity : context.peer_identity; + delivery.incoming.source_unverified = context.source_unverified; delivery.incoming.rx_meta = context.rx_meta; delivery.text = payload.content; delivery.incoming.text = delivery.text; + delivery.payload = payload; *out_delivery = std::move(delivery); return true; diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp index 5d87521e..66e97bd7 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp @@ -7,10 +7,8 @@ #include "../../internal/blob_store_io.h" #include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" - -#include -#include -#include +#include "platform/esp/common/reticulum_crypto_compat.h" +#include "platform/esp/common/reticulum_runtime_compat.h" #include #include diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp index e7c4b341..9dfc6076 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp @@ -38,8 +38,124 @@ void copyHash(uint8_t* out, const uint8_t* in, std::size_t len) std::memcpy(out, in, len); } +uint64_t announceTimebase( + const uint8_t random_blob[kAnnounceRandomBlobSize]) +{ + uint64_t emitted = 0; + for (std::size_t index = 5; index < kAnnounceRandomBlobSize; ++index) + { + emitted = (emitted << 8U) | random_blob[index]; + } + return emitted; +} + +bool hasRandomBlob(const PathEntry& path, + const uint8_t random_blob[kAnnounceRandomBlobSize]) +{ + for (std::size_t index = 0; index < path.announce_random_blob_count; ++index) + { + if (hashesEqual(path.announce_random_blobs[index], + random_blob, + kAnnounceRandomBlobSize)) + { + return true; + } + } + return false; +} + +void appendRandomBlob(PathEntry& path, + const uint8_t random_blob[kAnnounceRandomBlobSize]) +{ + if (hasRandomBlob(path, random_blob)) + { + return; + } + + if (path.announce_random_blob_count < kPathRandomBlobHistory) + { + copyHash(path.announce_random_blobs[path.announce_random_blob_count], + random_blob, + kAnnounceRandomBlobSize); + ++path.announce_random_blob_count; + return; + } + + std::memmove(path.announce_random_blobs[0], + path.announce_random_blobs[1], + (kPathRandomBlobHistory - 1U) * kAnnounceRandomBlobSize); + copyHash(path.announce_random_blobs[kPathRandomBlobHistory - 1U], + random_blob, + kAnnounceRandomBlobSize); +} + } // namespace +bool pathAnnounceAccepted(PathAnnounceDecision decision) +{ + return decision == PathAnnounceDecision::AcceptNew || + decision == PathAnnounceDecision::AcceptNewer || + decision == PathAnnounceDecision::AcceptExpired; +} + +bool pathExpired(const PathEntry& path, uint32_t now_ms, uint32_t path_ttl_ms) +{ + if (path.updated_ms == 0) + { + return true; + } + return path_ttl_ms != 0 && (now_ms - path.updated_ms) > path_ttl_ms; +} + +PathAnnounceDecision evaluatePathAnnounce( + const PathEntry* existing, + uint8_t hops, + const uint8_t random_blob[kAnnounceRandomBlobSize], + uint32_t now_ms, + uint32_t path_ttl_ms) +{ + if (!existing) + { + return PathAnnounceDecision::AcceptNew; + } + if (!random_blob || hasRandomBlob(*existing, random_blob)) + { + return PathAnnounceDecision::RejectReplay; + } + + const uint64_t emitted = announceTimebase(random_blob); + if (hops <= existing->hops) + { + return emitted > existing->announce_timebase + ? PathAnnounceDecision::AcceptNewer + : PathAnnounceDecision::RejectStale; + } + if (pathExpired(*existing, now_ms, path_ttl_ms)) + { + return PathAnnounceDecision::AcceptExpired; + } + return emitted > existing->announce_timebase + ? PathAnnounceDecision::AcceptNewer + : PathAnnounceDecision::RejectStale; +} + +void applyPathAnnounce(PathEntry& path, + uint8_t hops, + const uint8_t random_blob[kAnnounceRandomBlobSize], + uint32_t now_ms, + uint32_t last_seen_s) +{ + path.hops = hops; + path.last_seen_s = last_seen_s; + path.updated_ms = now_ms == 0 ? 1U : now_ms; + if (random_blob) + { + path.announce_timebase = + std::max(path.announce_timebase, announceTimebase(random_blob)); + appendRandomBlob(path, random_blob); + } +} + bool isDuplicatePacket(const TransportRuntime& transport, const uint8_t packet_hash[reticulum::kFullHashSize]) { @@ -79,6 +195,25 @@ void rememberPacket(TransportRuntime& transport, transport.packet_filter.push_back(entry); } +void forgetPacket(TransportRuntime& transport, + const uint8_t packet_hash[reticulum::kFullHashSize]) +{ + if (!packet_hash) + { + return; + } + transport.packet_filter.erase( + std::remove_if(transport.packet_filter.begin(), + transport.packet_filter.end(), + [packet_hash](const PacketFilterEntry& entry) + { + return hashesEqual(entry.packet_hash, + packet_hash, + sizeof(entry.packet_hash)); + }), + transport.packet_filter.end()); +} + void rememberReversePath(TransportRuntime& transport, const uint8_t proof_hash[reticulum::kTruncatedHashSize], uint8_t expected_hops, @@ -221,6 +356,78 @@ void resolvePendingPathRequest(TransportRuntime& transport, transport.pending_path_requests.end()); } +void notePendingPingReceipt( + TransportRuntime& transport, + const uint8_t packet_hash[reticulum::kFullHashSize], + const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t peer_sig_pub[LxmfIdentity::kSigPubKeySize], + uint32_t now_ms, + std::size_t max_pending_ping_receipts) +{ + if (!packet_hash || !destination_hash || !peer_sig_pub) + { + return; + } + + uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + copyHash(proof_hash, packet_hash, sizeof(proof_hash)); + removePendingPingReceipt(transport, proof_hash); + if (max_pending_ping_receipts != 0 && + transport.pending_ping_receipts.size() >= max_pending_ping_receipts) + { + transport.pending_ping_receipts.erase(transport.pending_ping_receipts.begin()); + } + + transport.pending_ping_receipts.push_back(PendingPingReceipt{}); + PendingPingReceipt& receipt = transport.pending_ping_receipts.back(); + copyHash(receipt.packet_hash, packet_hash, sizeof(receipt.packet_hash)); + copyHash(receipt.proof_hash, proof_hash, sizeof(receipt.proof_hash)); + copyHash(receipt.destination_hash, + destination_hash, + sizeof(receipt.destination_hash)); + copyHash(receipt.peer_sig_pub, peer_sig_pub, sizeof(receipt.peer_sig_pub)); + receipt.created_ms = now_ms == 0 ? 1U : now_ms; +} + +PendingPingReceipt* findPendingPingReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]) +{ + if (!proof_hash) + { + return nullptr; + } + for (auto& receipt : transport.pending_ping_receipts) + { + if (receipt.created_ms != 0 && + hashesEqual(receipt.proof_hash, proof_hash, sizeof(receipt.proof_hash))) + { + return &receipt; + } + } + return nullptr; +} + +void removePendingPingReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]) +{ + if (!proof_hash) + { + return; + } + transport.pending_ping_receipts.erase( + std::remove_if(transport.pending_ping_receipts.begin(), + transport.pending_ping_receipts.end(), + [proof_hash](const PendingPingReceipt& receipt) + { + return hashesEqual(receipt.proof_hash, + proof_hash, + sizeof(receipt.proof_hash)); + }), + transport.pending_ping_receipts.end()); +} + PathEntry& upsertPath(TransportRuntime& transport, const uint8_t destination_hash[reticulum::kTruncatedHashSize], std::size_t max_paths) @@ -235,7 +442,14 @@ PathEntry& upsertPath(TransportRuntime& transport, if (max_paths != 0 && transport.paths.size() >= max_paths) { - transport.paths.erase(transport.paths.begin()); + const auto oldest = std::min_element( + transport.paths.begin(), + transport.paths.end(), + [](const PathEntry& lhs, const PathEntry& rhs) + { + return lhs.updated_ms < rhs.updated_ms; + }); + transport.paths.erase(oldest); } transport.paths.push_back(PathEntry{}); @@ -305,6 +519,18 @@ void cullTransportRuntime(TransportRuntime& transport, uint32_t now_ms, const TransportRuntimeLimits& limits) { + if (limits.path_ttl_ms != 0) + { + transport.paths.erase( + std::remove_if(transport.paths.begin(), + transport.paths.end(), + [now_ms, &limits](const PathEntry& path) + { + return pathExpired(path, now_ms, limits.path_ttl_ms); + }), + transport.paths.end()); + } + transport.packet_filter.erase( std::remove_if(transport.packet_filter.begin(), transport.packet_filter.end(), [now_ms, &limits](const PacketFilterEntry& entry) @@ -342,6 +568,29 @@ void cullTransportRuntime(TransportRuntime& transport, limits.pending_path_request_ttl_ms; }), transport.pending_path_requests.end()); + + if (limits.pending_ping_receipt_ttl_ms != 0) + { + transport.pending_ping_receipts.erase( + std::remove_if(transport.pending_ping_receipts.begin(), + transport.pending_ping_receipts.end(), + [now_ms, &limits](const PendingPingReceipt& receipt) + { + return receipt.created_ms == 0 || + (now_ms - receipt.created_ms) > + limits.pending_ping_receipt_ttl_ms; + }), + transport.pending_ping_receipts.end()); + } + if (limits.max_pending_ping_receipts != 0 && + transport.pending_ping_receipts.size() > limits.max_pending_ping_receipts) + { + const std::size_t excess = + transport.pending_ping_receipts.size() - limits.max_pending_ping_receipts; + transport.pending_ping_receipts.erase( + transport.pending_ping_receipts.begin(), + transport.pending_ping_receipts.begin() + excess); + } } } // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp index b30598de..287c98ff 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp @@ -6,12 +6,17 @@ #include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h" #include "chat/time_utils.h" +#if defined(ARDUINO) #include "platform/esp/arduino_common/app_tasks.h" +#endif +#include "platform/esp/common/reticulum_runtime_compat.h" #include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/wifi_access_runtime.h" #include "platform/ui/wifi_runtime.h" -#include +#if TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE +#include "platform/esp/idf_common/wireless_companion/c6_companion.h" +#endif #include #include @@ -249,12 +254,18 @@ void WifiGatewayReticulumInterface::maintain() return; } + syncSocketState(); if (connected()) { readAvailable(); return; } + if (socket_open_pending_) + { + return; + } + (void)ensureSocket(); } @@ -270,7 +281,8 @@ bool WifiGatewayReticulumInterface::isConfigured() const bool WifiGatewayReticulumInterface::sendPacket(const uint8_t* data, size_t len, - const uint8_t* call_link_id) + const uint8_t* call_link_id, + bool call_admission_control) { if (!data || len == 0 || len > reticulum::kReticulumMtu) { @@ -287,7 +299,12 @@ bool WifiGatewayReticulumInterface::sendPacket(const uint8_t* data, const auto budget = platform::ui::wifi_access::traffic_budget( platform::ui::wifi_access::Client::ReticulumGateway, platform::ui::wifi_access::Priority::Messaging, - call_link_id); + call_link_id, + call_admission_control + ? platform::ui::wifi_access::AccessKind::ReticulumGatewayCallControl + : (call_link_id + ? platform::ui::wifi_access::AccessKind::ReticulumGatewayCallAudio + : platform::ui::wifi_access::AccessKind::LongLivedSocket)); if (!budget.allow_write || budget.tx_byte_budget == 0) { return false; @@ -314,7 +331,7 @@ bool WifiGatewayReticulumInterface::sendPacket(const uint8_t* data, } tx_frame_[tx_len++] = kHdlcFlag; -#if TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE const size_t written = client_.write(tx_frame_, tx_len); if (written != tx_len) { @@ -331,6 +348,27 @@ bool WifiGatewayReticulumInterface::sendPacket(const uint8_t* data, host_, static_cast(port_)); return true; +#elif TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE + auto& transport = + ::platform::esp::idf_common::wireless_companion::c6_wifi_tcp_transport(); + if (!transport.writeTcp(tx_frame_, tx_len)) + { + const auto status = transport.tcpStatus(); + Serial.printf("[Reticulum][IF][WiFi][TX] failed raw_len=%u frame_len=%u state=%u error=%u detail=%s\n", + static_cast(len), + static_cast(tx_len), + static_cast(status.state), + static_cast(status.error_code), + status.detail ? status.detail : "-"); + stop(); + return false; + } + Serial.printf("[Reticulum][IF][WiFi][TX] ok raw_len=%u frame_len=%u host=%s:%u transport=c6\n", + static_cast(len), + static_cast(tx_len), + host_, + static_cast(port_)); + return true; #else (void)tx_len; return false; @@ -361,10 +399,16 @@ bool WifiGatewayReticulumInterface::pollPacket(RxPacket* out) void WifiGatewayReticulumInterface::stop() { -#if TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE client_.stop(); +#elif TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE + if (socket_online_ || socket_open_pending_) + { + ::platform::esp::idf_common::wireless_companion::c6_wifi_tcp_transport().closeTcp(); + } #endif socket_online_ = false; + socket_open_pending_ = false; } bool WifiGatewayReticulumInterface::connected() const @@ -372,7 +416,7 @@ bool WifiGatewayReticulumInterface::connected() const return socket_online_; } -#if TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE bool WifiGatewayReticulumInterface::resolveHost(IPAddress* out) { if (!out || host_[0] == '\0') @@ -430,6 +474,59 @@ bool WifiGatewayReticulumInterface::resolveHost(IPAddress* out) } #endif +void WifiGatewayReticulumInterface::syncSocketState() +{ +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE + if (socket_online_ && !client_.connected()) + { + Serial.printf("[Reticulum][IF][WiFi] gateway disconnected host=%s port=%u\n", + host_, + static_cast(port_)); + client_.stop(); + socket_online_ = false; + socket_open_pending_ = false; + } +#elif TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE + using ::platform::esp::idf_common::wireless_companion::WifiTcpState; + auto& transport = + ::platform::esp::idf_common::wireless_companion::c6_wifi_tcp_transport(); + const auto status = transport.tcpStatus(); + if (status.state == WifiTcpState::Connected) + { + if (!socket_online_) + { + hdlc_in_frame_ = false; + hdlc_escape_ = false; + hdlc_frame_len_ = 0; + Serial.printf("[Reticulum][IF][WiFi] gateway connected host=%s port=%u transport=c6\n", + host_, + static_cast(port_)); + } + socket_online_ = true; + socket_open_pending_ = false; + return; + } + if (status.state == WifiTcpState::Opening) + { + socket_online_ = false; + socket_open_pending_ = true; + return; + } + + if (socket_online_ || socket_open_pending_) + { + Serial.printf("[Reticulum][IF][WiFi] gateway disconnected host=%s port=%u state=%u error=%u detail=%s\n", + host_, + static_cast(port_), + static_cast(status.state), + static_cast(status.error_code), + status.detail ? status.detail : "-"); + } + socket_online_ = false; + socket_open_pending_ = false; +#endif +} + bool WifiGatewayReticulumInterface::ensureSocket() { if (!transport_enabled_ || !enabled_ || host_[0] == '\0') @@ -484,16 +581,6 @@ bool WifiGatewayReticulumInterface::ensureSocket() } last_reconnect_ms_ = now_ms; - client_.stop(); - - IPAddress remote_ip{}; - if (!resolveHost(&remote_ip)) - { - socket_online_ = false; - client_.stop(); - return false; - } - platform::ui::wifi_access::Request socket_request{}; socket_request.client = platform::ui::wifi_access::Client::ReticulumGateway; socket_request.kind = platform::ui::wifi_access::AccessKind::LongLivedSocket; @@ -503,7 +590,7 @@ bool WifiGatewayReticulumInterface::ensureSocket() if (!lease.granted) { socket_online_ = false; - client_.stop(); + socket_open_pending_ = false; Serial.printf("[Reticulum][IF][WiFi] gateway connect deferred decision=%s host=%s port=%u\n", platform::ui::wifi_access::decision_name(lease.decision), host_, @@ -511,6 +598,17 @@ bool WifiGatewayReticulumInterface::ensureSocket() return false; } +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE + client_.stop(); + + IPAddress remote_ip{}; + if (!resolveHost(&remote_ip)) + { + socket_online_ = false; + client_.stop(); + return false; + } + Serial.printf("[Reticulum][IF][WiFi] gateway connect host=%s port=%u\n", host_, static_cast(port_)); @@ -533,12 +631,50 @@ bool WifiGatewayReticulumInterface::ensureSocket() host_, static_cast(port_)); return true; +#elif TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE + auto& transport = + ::platform::esp::idf_common::wireless_companion::c6_wifi_tcp_transport(); + const auto tcp_status = transport.tcpStatus(); + using ::platform::esp::idf_common::wireless_companion::WifiTcpState; + if (tcp_status.state == WifiTcpState::Connected) + { + socket_online_ = true; + socket_open_pending_ = false; + return true; + } + if (tcp_status.state == WifiTcpState::Opening) + { + socket_open_pending_ = true; + return true; + } + + Serial.printf("[Reticulum][IF][WiFi] gateway connect host=%s port=%u transport=c6\n", + host_, + static_cast(port_)); + if (!transport.openTcp(host_, port_)) + { + const auto failed = transport.tcpStatus(); + socket_online_ = false; + socket_open_pending_ = false; + Serial.printf("[Reticulum][IF][WiFi] gateway connect failed host=%s port=%u state=%u error=%u detail=%s\n", + host_, + static_cast(port_), + static_cast(failed.state), + static_cast(failed.error_code), + failed.detail ? failed.detail : "-"); + return false; + } + socket_open_pending_ = true; + return true; +#else + return false; +#endif #endif } void WifiGatewayReticulumInterface::readAvailable() { -#if TRAIL_MATE_RETICULUM_WIFI_GATEWAY_AVAILABLE +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE if (!socket_online_) { return; @@ -596,6 +732,52 @@ void WifiGatewayReticulumInterface::readAvailable() --remaining; } rx_stats_bytes_ += static_cast(processed); +#elif TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE + if (!socket_online_) + { + return; + } + + auto& transport = + ::platform::esp::idf_common::wireless_companion::c6_wifi_tcp_transport(); + const auto tcp_status = transport.tcpStatus(); + const int remaining = static_cast(tcp_status.buffered_bytes); + const uint32_t now_ms = millis(); + const auto budget = platform::ui::wifi_access::traffic_budget( + platform::ui::wifi_access::Client::ReticulumGateway, + platform::ui::wifi_access::Priority::Messaging); + if (!budget.allow_read || budget.rx_byte_budget == 0) + { + if (remaining > 0) + { + ++rx_stats_read_skips_; + } + if (!budget.allow_connect && !budget.allow_write) + { + stop(); + last_reconnect_ms_ = now_ms; + } + return; + } + if (remaining > 0 && last_socket_read_ms_ != 0 && + (now_ms - last_socket_read_ms_) < budget.min_read_interval_ms) + { + ++rx_stats_read_skips_; + return; + } + if (remaining > 0) + { + last_socket_read_ms_ = now_ms; + } + + const size_t read_budget = std::min(budget.rx_byte_budget, + sizeof(socket_rx_scratch_)); + const size_t read = transport.readTcp(socket_rx_scratch_, read_budget); + for (size_t index = 0; index < read; ++index) + { + feedHdlcByte(socket_rx_scratch_[index]); + } + rx_stats_bytes_ += static_cast(read); #endif } @@ -797,7 +979,8 @@ bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) bool ReticulumInterfaceSet::sendPacketWifiOnly(const uint8_t* data, size_t len, - const uint8_t* call_link_id) + const uint8_t* call_link_id, + bool call_admission_control) { last_tx_result_ = {}; if (!data || len == 0) @@ -811,7 +994,8 @@ bool ReticulumInterfaceSet::sendPacketWifiOnly(const uint8_t* data, last_tx_result_.wifi_ready = last_tx_result_.wifi_required && wifi_.isReady(); if (last_tx_result_.wifi_ready) { - last_tx_result_.wifi_ok = wifi_.sendPacket(data, len, call_link_id); + last_tx_result_.wifi_ok = + wifi_.sendPacket(data, len, call_link_id, call_admission_control); } const bool sent = last_tx_result_.sent(); @@ -911,7 +1095,7 @@ bool ReticulumInterfaceSet::wifiAllowed() const bool ReticulumInterfaceSet::loraSelectedForRuntime() const { - if (!loraAllowed()) + if (!loraAllowed() || ::platform::ui::reticulum_call::resource_preempt_active()) { return false; } @@ -931,8 +1115,8 @@ bool ReticulumInterfaceSet::wifiSelectedForRuntime() const void ReticulumInterfaceSet::syncSharedLoRaRxGate() { - const bool suppress = !loraSelectedForRuntime() || - ::platform::ui::reticulum_call::resource_preempt_active(); + const bool suppress = !loraSelectedForRuntime(); +#if defined(ARDUINO) if (shared_lora_rx_suppressed_ == suppress && app::AppTasks::isRadioReceiveSuppressed() == suppress) { @@ -940,6 +1124,9 @@ void ReticulumInterfaceSet::syncSharedLoRaRxGate() } shared_lora_rx_suppressed_ = suppress; app::AppTasks::setRadioReceiveSuppressed(suppress); +#else + shared_lora_rx_suppressed_ = suppress; +#endif } } // namespace chat::reticulum::interfaces diff --git a/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp index 4d898d04..69fa8758 100644 --- a/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp @@ -6,9 +6,10 @@ #include "platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h" #include "chat/time_utils.h" +#if defined(ARDUINO) #include "platform/esp/arduino_common/app_tasks.h" -#include -#include +#endif +#include "platform/esp/common/reticulum_runtime_compat.h" #include #include #include @@ -27,6 +28,8 @@ constexpr float kDefaultBandwidthKHz = 125.0f; constexpr uint8_t kDefaultSpreadingFactor = 9; constexpr uint8_t kDefaultCodingRate = 5; constexpr int8_t kDefaultTxPowerDbm = 17; +constexpr int kRadioOk = 0; +constexpr int kRadioUnsupported = -1; template T clampValue(T value, T min_value, T max_value) @@ -127,17 +130,19 @@ bool RNodeAdapter::sendAppData(ChannelId channel, uint32_t portnum, static_cast(air_packets.first_len), static_cast(air_packets.second_len)); - int first_state = RADIOLIB_ERR_UNSUPPORTED; - int second_state = RADIOLIB_ERR_NONE; + int first_state = kRadioUnsupported; + int second_state = kRadioOk; { +#if defined(ARDUINO) app::AppTasks::ScopedRadioTransmitActivity tx_activity; +#endif first_state = board_.transmitRadio(air_packets.first, air_packets.first_len); - if (first_state == RADIOLIB_ERR_NONE && air_packets.count > 1U) + if (first_state == kRadioOk && air_packets.count > 1U) { second_state = board_.transmitRadio(air_packets.second, air_packets.second_len); } } - if (first_state != RADIOLIB_ERR_NONE) + if (first_state != kRadioOk) { Serial.printf("[RNode][TX] result ok=0 first=%d second=%d\n", first_state, @@ -146,7 +151,7 @@ bool RNodeAdapter::sendAppData(ChannelId channel, uint32_t portnum, return false; } - if (second_state != RADIOLIB_ERR_NONE) + if (second_state != kRadioOk) { Serial.printf("[RNode][TX] result ok=0 first=%d second=%d\n", first_state, diff --git a/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp b/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp index c2e40563..e2ff3920 100644 --- a/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp +++ b/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp @@ -22,7 +22,35 @@ namespace namespace storage = ::platform::esp::arduino_common::storage; constexpr uint8_t kRecordHasReticulumIdentityFlag = 0x01U; +constexpr uint8_t kRecordSourceUnverifiedFlag = 0x02U; constexpr uint8_t kIndexHasReticulumIdentityFlag = 0x01U; +// FileHeader::reserved is a durable unread count once the valid bit is set. +constexpr uint16_t kUnreadStateValidMask = 0x8000U; +constexpr uint16_t kUnreadStateCountMask = 0x7FFFU; +constexpr const char* kTempIndexFile = "/chat/index.tmp"; +constexpr const char* kBackupIndexFile = "/chat/index.bak"; + +static_assert(SdStore::kMaxMessagesPerConv <= kUnreadStateCountMask, + "Unread state must cover a full conversation ring"); + +uint16_t encodeUnreadState(uint16_t unread) +{ + return static_cast(kUnreadStateValidMask | + std::min(unread, kUnreadStateCountMask)); +} + +bool decodeUnreadState(uint16_t state, uint16_t* unread) +{ + if ((state & kUnreadStateValidMask) == 0) + { + return false; + } + if (unread) + { + *unread = static_cast(state & kUnreadStateCountMask); + } + return true; +} bool readExact(storage::SdRuntimeFile& file, void* out, size_t len) { @@ -112,11 +140,16 @@ SdStore::SdStore() } std::vector entries; - if (!readIndex(entries)) + if (!readIndex(entries) && + (!recoverIndex() || !readIndex(entries))) { rebuildIndex(); (void)readIndex(entries); } + if (reconcileIndexUnread(entries)) + { + unread_reconcile_pending_ = !writeIndex(entries); + } Serial.printf("[AppContext] chat store=SdStore layout=logs root=%s index=%u\n", kDir, static_cast(entries.size())); @@ -137,6 +170,18 @@ void SdStore::append(const ChatMessage& msg) return; } + uint16_t unread = 0; + if (!decodeUnreadState(header.reserved, &unread)) + { + unread = static_cast( + std::min(std::max(0, getUnread(conv)), kUnreadStateCountMask)); + } + if (msg.status == MessageStatus::Incoming && unread < kUnreadStateCountMask) + { + unread = static_cast(unread + 1U); + } + header.reserved = encodeUnreadState(unread); + const Record rec = recordFromMessage(msg); if (!writeRecord(file, header.head, rec)) { @@ -155,10 +200,14 @@ void SdStore::append(const ChatMessage& msg) file.close(); return; } - file.flush(); + if (!file.flush()) + { + file.close(); + return; + } file.close(); - updateIndexForMessage(msg); + updateIndexForMessage(msg, unread); } std::vector SdStore::loadRecent(const ConversationId& conv, size_t n) @@ -291,8 +340,24 @@ void SdStore::setUnread(const ConversationId& conv, int unread) { return; } - entries[index].unread = static_cast(std::max(0, unread)); - (void)writeIndex(entries); + const uint16_t unread_count = static_cast( + std::min(std::max(0, unread), kUnreadStateCountMask)); + const bool header_written = writeConversationUnread(conv, unread_count); + if (!header_written) + { + Serial.printf("[AppContext] chat unread persist failed stage=conversation_header unread=%u\n", + static_cast(unread_count)); + return; + } + entries[index].unread = unread_count; + const bool index_written = writeIndex(entries); + if (!index_written) + { + Serial.printf("[AppContext] chat unread persist failed stage=index unread=%u\n", + static_cast(unread_count)); + unread_reconcile_pending_ = true; + rebuildIndex(); + } } int SdStore::getUnread(const ConversationId& conv) const @@ -351,6 +416,14 @@ void SdStore::clearAll() { storage::sd_remove(kIndexFile); } + if (storage::sd_exists(kTempIndexFile)) + { + storage::sd_remove(kTempIndexFile); + } + if (storage::sd_exists(kBackupIndexFile)) + { + storage::sd_remove(kBackupIndexFile); + } std::vector log_paths; storage::SdRuntimeDir dir; @@ -375,6 +448,7 @@ void SdStore::clearAll() storage::sd_remove(path.c_str()); } } + unread_reconcile_pending_ = false; } bool SdStore::updateMessageStatus(MessageId msg_id, MessageStatus status) @@ -536,6 +610,52 @@ bool SdStore::ensureDir() const return storage::sd_mkdir(kDir); } +bool SdStore::recoverIndex() const +{ + if (storage::sd_exists(kIndexFile)) + { + return false; + } + const auto restore_candidate = [this](const char* path, const char* source) + { + if (!storage::sd_exists(path) || !storage::sd_rename(path, kIndexFile)) + { + return false; + } + std::vector recovered; + if (readIndex(recovered)) + { + Serial.printf("[AppContext] chat store=SdStore recovered index source=%s\n", + source); + return true; + } + (void)storage::sd_remove(kIndexFile); + return false; + }; + + if (restore_candidate(kTempIndexFile, "temp")) + { + return true; + } + return restore_candidate(kBackupIndexFile, "backup"); +} + +bool SdStore::reconcileIndexUnread(std::vector& entries) const +{ + bool changed = false; + for (auto& entry : entries) + { + uint16_t durable_unread = 0; + if (readConversationUnread(conversationFromIndexEntry(entry), &durable_unread) && + entry.unread != durable_unread) + { + entry.unread = durable_unread; + changed = true; + } + } + return changed; +} + bool SdStore::readIndex(std::vector& entries) const { entries.clear(); @@ -609,7 +729,6 @@ bool SdStore::writeIndex(const std::vector& entries) const return false; } - static constexpr const char* kTempIndexFile = "/chat/index.tmp"; if (storage::sd_exists(kTempIndexFile)) { storage::sd_remove(kTempIndexFile); @@ -639,26 +758,62 @@ bool SdStore::writeIndex(const std::vector& entries) const return false; } - if (storage::sd_exists(kIndexFile)) + const bool had_index = storage::sd_exists(kIndexFile); + if (had_index) { - storage::sd_remove(kIndexFile); + if (storage::sd_exists(kBackupIndexFile) && + !storage::sd_remove(kBackupIndexFile)) + { + storage::sd_remove(kTempIndexFile); + return false; + } + if (!storage::sd_rename(kIndexFile, kBackupIndexFile)) + { + storage::sd_remove(kTempIndexFile); + return false; + } } if (!storage::sd_rename(kTempIndexFile, kIndexFile)) { + if (had_index && !storage::sd_exists(kIndexFile)) + { + (void)storage::sd_rename(kBackupIndexFile, kIndexFile); + } storage::sd_remove(kTempIndexFile); return false; } + if (storage::sd_exists(kBackupIndexFile)) + { + (void)storage::sd_remove(kBackupIndexFile); + } return true; } bool SdStore::ensureIndex(std::vector& entries) { - if (readIndex(entries)) + bool loaded = readIndex(entries); + if (!loaded && recoverIndex()) { - return true; + loaded = readIndex(entries); } - rebuildIndex(); - return readIndex(entries); + if (!loaded) + { + rebuildIndex(); + loaded = readIndex(entries); + } + if (!loaded) + { + return false; + } + if (unread_reconcile_pending_) + { + const bool changed = reconcileIndexUnread(entries); + if (!changed || writeIndex(entries)) + { + unread_reconcile_pending_ = false; + } + } + return true; } bool SdStore::findIndexEntry(const ConversationId& conv, @@ -689,7 +844,7 @@ bool SdStore::findIndexEntry(const ConversationId& conv, return false; } -void SdStore::updateIndexForMessage(const ChatMessage& msg) +void SdStore::updateIndexForMessage(const ChatMessage& msg, uint16_t unread) { std::vector entries; if (!ensureIndex(entries)) @@ -717,6 +872,7 @@ void SdStore::updateIndexForMessage(const ChatMessage& msg) } IndexEntry& entry = entries[index]; + entry.unread = std::min(unread, kUnreadStateCountMask); entry.protocol = static_cast(msg.protocol); entry.channel = static_cast(msg.channel); entry.status = static_cast(msg.status); @@ -740,12 +896,12 @@ void SdStore::updateIndexForMessage(const ChatMessage& msg) { std::memcpy(entry.preview, msg.text.data(), entry.preview_len); } - if (msg.status == MessageStatus::Incoming && entry.unread < 0xFFFFU) + const bool index_written = writeIndex(entries); + if (!index_written) { - entry.unread = static_cast(entry.unread + 1U); + unread_reconcile_pending_ = true; + rebuildIndex(); } - - (void)writeIndex(entries); } void SdStore::rebuildIndex() @@ -788,6 +944,7 @@ void SdStore::rebuildIndex() ChatMessage last_msg; bool have_last = false; uint16_t unread = 0; + const bool has_durable_unread = decodeUnreadState(header.reserved, &unread); for (uint16_t index = 0; index < header.count; ++index) { const uint16_t slot = @@ -799,7 +956,9 @@ void SdStore::rebuildIndex() continue; } ChatMessage msg = messageFromRecord(rec); - if (msg.status == MessageStatus::Incoming && unread < 0xFFFFU) + if (!has_durable_unread && + msg.status == MessageStatus::Incoming && + unread < kUnreadStateCountMask) { ++unread; } @@ -839,10 +998,14 @@ void SdStore::rebuildIndex() std::memcpy(entry.preview, last_msg.text.data(), entry.preview_len); } entries.push_back(entry); + if (!has_durable_unread) + { + (void)writeConversationUnread(conversationIdForMessage(last_msg), unread); + } } dir.close(); - (void)writeIndex(entries); + unread_reconcile_pending_ = !writeIndex(entries); Serial.printf("[AppContext] chat store=SdStore rebuild index entries=%u\n", static_cast(entries.size())); } @@ -1051,6 +1214,53 @@ bool SdStore::openConversationForUpdate(const ConversationId& conv, return loadFileHeader(file, header); } +bool SdStore::readConversationUnread(const ConversationId& conv, uint16_t* unread) const +{ + if (!unread) + { + return false; + } + + char path[96]{}; + buildConversationPath(conv, path, sizeof(path)); + storage::SdRuntimeFile file; + if (!storage::sd_exists(path) || !file.open(path, "r")) + { + return false; + } + + FileHeader header{}; + const bool ok = loadFileHeader(file, header) && + decodeUnreadState(header.reserved, unread); + file.close(); + return ok; +} + +bool SdStore::writeConversationUnread(const ConversationId& conv, uint16_t unread) const +{ + char path[96]{}; + buildConversationPath(conv, path, sizeof(path)); + storage::SdRuntimeFile file; + if (!storage::sd_exists(path) || !file.open(path, "r+")) + { + return false; + } + + FileHeader header{}; + if (!loadFileHeader(file, header) || + !upgradeConversationFile(file, path, header)) + { + file.close(); + return false; + } + header.reserved = encodeUnreadState(unread); + const bool ok = file.seek(0) && + writeExact(file, &header, sizeof(header)) && + file.flush(); + file.close(); + return ok; +} + void SdStore::buildConversationPath(const ConversationId& conv, char* out, size_t out_len) const { if (!out || out_len == 0) @@ -1116,6 +1326,7 @@ ChatMessage SdStore::messageFromRecord(const Record& rec) msg.text.assign(rec.text, std::min(rec.text_len, sizeof(rec.text))); msg.status = static_cast(rec.status); msg.rx_origin = static_cast(rec.rx_origin); + msg.source_unverified = (rec.flags & kRecordSourceUnverifiedFlag) != 0; if ((rec.flags & kRecordHasReticulumIdentityFlag) != 0) { readReticulumIdentityFromStorage(msg.reticulum_identity, @@ -1137,6 +1348,10 @@ SdStore::Record SdStore::recordFromMessage(const ChatMessage& msg) rec.msg_id = msg.msg_id; rec.timestamp = msg.timestamp; rec.rx_origin = static_cast(msg.rx_origin); + if (msg.source_unverified) + { + rec.flags |= kRecordSourceUnverifiedFlag; + } if (msg.protocol == MeshProtocol::Reticulum && hasReticulumDestinationIdentity(msg.reticulum_identity)) { diff --git a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp index 92f14b1f..f93959bc 100644 --- a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp @@ -1135,8 +1135,22 @@ void reverse_records(Record* records, std::size_t count) uint32_t find_existing_first_seen(const char* path, const std::string& destination, std::size_t first_seen_field, - uint32_t fallback) + uint32_t fallback, + DirectoryIoGate io_gate, + bool* out_deferred) { + if (out_deferred) + { + *out_deferred = false; + } + if (!io_gate_open(io_gate)) + { + if (out_deferred) + { + *out_deferred = true; + } + return fallback; + } if (!::platform::esp::arduino_common::storage::sd_exists(path)) { return fallback; @@ -1148,8 +1162,21 @@ uint32_t find_existing_first_seen(const char* path, } std::string line; LineReader reader(file); - while (reader.read_line(line)) + for (;;) { + if (!io_gate_open(io_gate)) + { + file.close(); + if (out_deferred) + { + *out_deferred = true; + } + return fallback; + } + if (!reader.read_line(line)) + { + break; + } const std::string_view view = trim_view(line); if (data_line(view) && first_field_matches(view, destination)) { @@ -1444,12 +1471,21 @@ Status record_announce_sync(const AnnounceRecord& record, bool require_maintenan const std::string destination = hex_text(record.destination_hash, kReticulumHashSize); const uint32_t fallback_first_seen = record.first_seen_s != 0 ? record.first_seen_s : record.last_seen_s; - const uint32_t first_seen_s = out.sd_present && !require_maintenance - ? find_existing_first_seen(kAnnouncesPath, - destination, - 4, - fallback_first_seen) - : fallback_first_seen; + bool first_seen_scan_deferred = false; + const uint32_t first_seen_s = + out.sd_present + ? find_existing_first_seen(kAnnouncesPath, + destination, + 4, + fallback_first_seen, + io_gate, + &first_seen_scan_deferred) + : fallback_first_seen; + if (first_seen_scan_deferred) + { + set_maintenance_deferred(out, kAnnouncesPath); + return out; + } return stream_upsert_line(kAnnouncesPath, kAnnouncesTempPath, "announces", diff --git a/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp index 4677be78..d36b6b58 100644 --- a/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_screen_runtime.cpp @@ -78,6 +78,11 @@ void enter_from_saver() enterFromScreenSaver(); } +void wake_for_modal() +{ + wakeScreenForModal(); +} + void update_user_activity() { updateUserActivity(); diff --git a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp index 31af0462..7fcbf788 100644 --- a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp @@ -2,7 +2,9 @@ #include "app/app_facade_access.h" #include "chat/ports/i_mesh_adapter.h" +#if defined(ARDUINO) #include "platform/esp/arduino_common/chat/infra/mesh_mqtt_client_runtime.h" +#endif #include "platform/ui/screen_runtime.h" #include "platform/ui/wifi_runtime.h" #include "sys/clock.h" @@ -128,6 +130,14 @@ bool request_is_call_audio_locked(const Request& request) call_link_matches_locked(request.call_link_id); } +bool request_is_call_control_locked(const Request& request) +{ + return request.client == Client::ReticulumGateway && + request.kind == AccessKind::ReticulumGatewayCallControl && + !link_is_empty(request.call_link_id) && + !call_link_matches_locked(request.call_link_id); +} + bool call_allows_request_locked(const Request& request) { if (!call_soft_preempt_active_locked()) @@ -144,7 +154,8 @@ bool call_allows_request_locked(const Request& request) } return request.kind == AccessKind::WifiConnect || request.kind == AccessKind::LongLivedSocket || - request_is_call_audio_locked(request); + request_is_call_audio_locked(request) || + request_is_call_control_locked(request); } ScreenPhase sample_screen_phase(std::uint32_t now_ms) @@ -370,7 +381,8 @@ bool long_lived_allowed(const Request& request, ScreenPhase phase, Lease& out) TrafficBudget base_budget(Client client, ScreenPhase phase, - const std::uint8_t* call_link_id) + const std::uint8_t* call_link_id, + AccessKind access_kind) { TrafficBudget budget{}; budget.screen_phase = phase; @@ -379,6 +391,10 @@ TrafficBudget base_budget(Client client, const bool call_preempt = call_soft_preempt_active_locked(); const bool call_exclusive = call_exclusive_active_locked(); const bool link_matches = call_link_matches_locked(call_link_id); + const bool call_control = + client == Client::ReticulumGateway && + access_kind == AccessKind::ReticulumGatewayCallControl && + !link_is_empty(call_link_id) && !link_matches; portEXIT_CRITICAL(&s_lock); if (call_preempt) @@ -387,11 +403,11 @@ TrafficBudget base_budget(Client client, { budget.allow_connect = true; budget.allow_read = true; - budget.allow_write = !call_exclusive || link_matches; + budget.allow_write = !call_exclusive || link_matches || call_control; budget.rx_packet_budget = 0; - budget.tx_packet_budget = 8; + budget.tx_packet_budget = call_control ? 2 : 8; budget.rx_byte_budget = 768; - budget.tx_byte_budget = 1024; + budget.tx_byte_budget = call_control ? 512 : 1024; budget.min_read_interval_ms = 0; } return budget; @@ -602,7 +618,9 @@ bool set_transport_enabled(bool enabled) } bool clients_ready = true; +#if defined(ARDUINO) ::platform::esp::arduino_common::mesh_mqtt::setWifiTransportEnabled(enabled); +#endif if (app::hasAppFacade()) { if (chat::IMeshAdapter* adapter = app::appFacade().getMeshAdapter()) @@ -932,11 +950,12 @@ void end_foreground_download(Client client, AccessKind kind) TrafficBudget traffic_budget(Client client, Priority priority, - const std::uint8_t* call_link_id) + const std::uint8_t* call_link_id, + AccessKind access_kind) { (void)priority; const ScreenPhase phase = sample_screen_phase(sys::millis_now()); - TrafficBudget budget = base_budget(client, phase, call_link_id); + TrafficBudget budget = base_budget(client, phase, call_link_id, access_kind); portENTER_CRITICAL(&s_lock); const std::uint32_t now_ms = sys::millis_now(); @@ -1064,6 +1083,8 @@ const char* access_kind_name(AccessKind kind) return "ota_download"; case AccessKind::ReticulumGatewayCallAudio: return "reticulum_gateway_call_audio"; + case AccessKind::ReticulumGatewayCallControl: + return "reticulum_gateway_call_control"; default: return "unknown"; } diff --git a/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp b/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp index 14f33323..a518614c 100644 --- a/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp +++ b/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp @@ -1,75 +1,31 @@ /** * @file reticulum_call_audio_runtime.cpp - * @brief Pager ES8311/Codec2 media hooks for MeshChat-compatible Reticulum calls. + * @brief Arduino board backend for the shared Reticulum Codec2 call engine. */ #include "platform/esp/arduino_common/reticulum_call_audio_runtime.h" -#include "platform/ui/reticulum_call_runtime.h" +#include "platform/esp/common/reticulum_call_audio_engine.h" #if defined(ARDUINO_T_LORA_PAGER) && \ (defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_LR1121)) #include "boards/tlora_pager/tlora_pager_board.h" -#include "chat/infra/reticulum/audio_call_wire.h" #include "platform/esp/boards/board_runtime.h" -#include -#include -#include -#include - -#include -#include -#include - namespace platform::esp::arduino_common { namespace { -using ::chat::reticulum::audio_call::Codec2Mode; -using ::chat::reticulum::audio_call::DecodedPayload; - -constexpr uint32_t kSampleRate = 8000; constexpr uint8_t kBitsPerSample = 16; -constexpr uint8_t kI2sChannels = 2; -constexpr uint8_t kCodecFramesPerPacket = 2; -constexpr uint8_t kJitterMinFrames = 3; -constexpr uint8_t kJitterMaxFrames = 16; +constexpr uint8_t kChannels = 1; constexpr uint8_t kDefaultVolume = 78; constexpr float kDefaultGainDb = 36.0f; -constexpr float kTxPcmGain = 1.5f; -constexpr float kRxPcmGain = 2.0f; -constexpr uint32_t kTaskStackBytes = 18 * 1024; -constexpr UBaseType_t kTaskPriority = tskIDLE_PRIORITY + 2; -TaskHandle_t s_task = nullptr; -volatile bool s_stop_requested = false; bool s_registered = false; ::boards::tlora_pager::TLoRaPagerBoard* s_board = nullptr; -uint32_t now_ms() -{ - return static_cast(pdTICKS_TO_MS(xTaskGetTickCount())); -} - -void* audio_alloc(std::size_t bytes) -{ - return heap_caps_malloc_prefer(bytes, - 2, - MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, - MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); -} - -void audio_free(void* ptr) -{ - if (ptr) - { - heap_caps_free(ptr); - } -} - ::boards::tlora_pager::TLoRaPagerBoard* resolve_board() { ::platform::esp::boards::AppContextInitHandles handles; @@ -87,282 +43,66 @@ bool media_supported() return board && ((board->getDevicesProbe() & HW_CODEC_ONLINE) != 0); } -int16_t clamp_sample(int32_t sample) +bool open_audio(uint32_t sample_rate_hz) { - if (sample > 32767) + s_board = resolve_board(); + if (!s_board || + s_board->codec.open(kBitsPerSample, kChannels, sample_rate_hz) != 0) { - return 32767; + s_board = nullptr; + return false; } - if (sample < -32768) - { - return -32768; - } - return static_cast(sample); + + s_board->codec.setVolume(s_board->getMessageToneVolume()); + s_board->codec.setGain(kDefaultGainDb); + s_board->codec.setMute(false); + s_board->codec.setOutMute(false); + return true; } -void free_buffers(int16_t* pcm_in_i2s, - int16_t* pcm_out_i2s, - int16_t* pcm_in, - int16_t* pcm_out, - int16_t* silence_i2s, - uint8_t* codec_frame, - uint8_t* tx_frames, - uint8_t* rx_frames, - uint8_t* packet_buf) -{ - audio_free(pcm_in_i2s); - audio_free(pcm_out_i2s); - audio_free(pcm_in); - audio_free(pcm_out); - audio_free(silence_i2s); - audio_free(codec_frame); - audio_free(tx_frames); - audio_free(rx_frames); - audio_free(packet_buf); -} - -void close_codec() +void close_audio() { if (s_board) { s_board->codec.setMute(true); + s_board->codec.setOutMute(true); s_board->codec.close(); } s_board = nullptr; } -void call_audio_task(void*) +bool read_mono(int16_t* pcm, std::size_t sample_count) { - CODEC2* codec2_state = codec2_create(CODEC2_MODE_1200); - if (!codec2_state) - { - close_codec(); - s_task = nullptr; - vTaskDelete(nullptr); - return; - } - codec2_set_lpc_post_filter(codec2_state, 1, 0, 0.8f, 0.2f); - - const int samples_per_frame = codec2_samples_per_frame(codec2_state); - const int bytes_per_frame = codec2_bytes_per_frame(codec2_state); - const int i2s_samples_per_frame = samples_per_frame * kI2sChannels; - const uint32_t frame_interval_ms = - static_cast((samples_per_frame * 1000U) / kSampleRate); - - auto* pcm_in_i2s = - static_cast(audio_alloc(i2s_samples_per_frame * sizeof(int16_t))); - auto* pcm_out_i2s = - static_cast(audio_alloc(i2s_samples_per_frame * sizeof(int16_t))); - auto* pcm_in = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); - auto* pcm_out = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); - auto* silence_i2s = - static_cast(audio_alloc(i2s_samples_per_frame * sizeof(int16_t))); - auto* codec_frame = static_cast(audio_alloc(bytes_per_frame)); - auto* tx_frames = - static_cast(audio_alloc(bytes_per_frame * kCodecFramesPerPacket)); - auto* rx_frames = - static_cast(audio_alloc(bytes_per_frame * kJitterMaxFrames)); - auto* packet_buf = - static_cast(audio_alloc(::platform::ui::reticulum_call::kAudioPacketMaxLen)); - - if (!pcm_in_i2s || !pcm_out_i2s || !pcm_in || !pcm_out || !silence_i2s || - !codec_frame || !tx_frames || !rx_frames || !packet_buf) - { - free_buffers(pcm_in_i2s, - pcm_out_i2s, - pcm_in, - pcm_out, - silence_i2s, - codec_frame, - tx_frames, - rx_frames, - packet_buf); - codec2_destroy(codec2_state); - close_codec(); - s_task = nullptr; - vTaskDelete(nullptr); - return; - } - std::memset(silence_i2s, 0, i2s_samples_per_frame * sizeof(int16_t)); - - uint8_t tx_frame_count = 0; - uint8_t rx_frame_count = 0; - uint8_t rx_head = 0; - uint8_t rx_tail = 0; - uint32_t last_play_ms = now_ms(); - - while (!s_stop_requested && - ::platform::ui::reticulum_call::media_should_run()) - { - const auto snapshot = ::platform::ui::reticulum_call::snapshot(); - - ::platform::ui::reticulum_call::AudioPacket inbound{}; - uint8_t inbound_drains = 0; - while (inbound_drains < 2 && - ::platform::ui::reticulum_call::dequeue_inbound_audio(&inbound)) - { - DecodedPayload decoded{}; - if (::chat::reticulum::audio_call::decodePayload( - inbound.data, - inbound.len, - &decoded) && - decoded.mode == Codec2Mode::Mode1200 && - decoded.encoded && - decoded.encoded_len >= static_cast(bytes_per_frame)) - { - const std::size_t frames = - decoded.encoded_len / static_cast(bytes_per_frame); - for (std::size_t index = 0; index < frames; ++index) - { - if (rx_frame_count >= kJitterMaxFrames) - { - break; - } - uint8_t* dst = rx_frames + (rx_tail * bytes_per_frame); - std::memcpy(dst, - decoded.encoded + (index * bytes_per_frame), - bytes_per_frame); - rx_tail = static_cast((rx_tail + 1U) % kJitterMaxFrames); - ++rx_frame_count; - } - } - ++inbound_drains; - } - - if (s_board && - s_board->codec.read(reinterpret_cast(pcm_in_i2s), - i2s_samples_per_frame * sizeof(int16_t)) == 0) - { - for (int index = 0; index < samples_per_frame; ++index) - { - const int32_t left = pcm_in_i2s[index * 2]; - const int32_t right = pcm_in_i2s[(index * 2) + 1]; - const int32_t mixed = static_cast(((left + right) / 2) * kTxPcmGain); - pcm_in[index] = clamp_sample(mixed); - } - - codec2_encode(codec2_state, codec_frame, pcm_in); - std::memcpy(tx_frames + (tx_frame_count * bytes_per_frame), - codec_frame, - bytes_per_frame); - ++tx_frame_count; - - if (tx_frame_count >= kCodecFramesPerPacket) - { - std::size_t packet_len = - ::platform::ui::reticulum_call::kAudioPacketMaxLen; - if (::chat::reticulum::audio_call::encodePayload( - Codec2Mode::Mode1200, - tx_frames, - bytes_per_frame * tx_frame_count, - packet_buf, - &packet_len)) - { - (void)::platform::ui::reticulum_call::enqueue_outbound_audio( - snapshot.link_id, - packet_buf, - packet_len); - } - tx_frame_count = 0; - } - } - - const uint32_t current_ms = now_ms(); - if (current_ms - last_play_ms >= frame_interval_ms) - { - last_play_ms = current_ms; - if (rx_frame_count >= kJitterMinFrames) - { - const uint8_t* frame = rx_frames + (rx_head * bytes_per_frame); - codec2_decode(codec2_state, pcm_out, const_cast(frame)); - rx_head = static_cast((rx_head + 1U) % kJitterMaxFrames); - --rx_frame_count; - - for (int index = 0; index < samples_per_frame; ++index) - { - const int32_t sample = - static_cast(pcm_out[index] * kRxPcmGain); - const int16_t out = clamp_sample(sample); - pcm_out_i2s[index * 2] = out; - pcm_out_i2s[(index * 2) + 1] = out; - } - if (s_board) - { - (void)s_board->codec.write(reinterpret_cast(pcm_out_i2s), - i2s_samples_per_frame * sizeof(int16_t)); - } - } - else if (s_board) - { - (void)s_board->codec.write(reinterpret_cast(silence_i2s), - i2s_samples_per_frame * sizeof(int16_t)); - } - } - - vTaskDelay(pdMS_TO_TICKS(2)); - } - - free_buffers(pcm_in_i2s, - pcm_out_i2s, - pcm_in, - pcm_out, - silence_i2s, - codec_frame, - tx_frames, - rx_frames, - packet_buf); - codec2_destroy(codec2_state); - close_codec(); - s_stop_requested = false; - s_task = nullptr; - vTaskDelete(nullptr); + return s_board && pcm && sample_count > 0 && + s_board->codec.read(reinterpret_cast(pcm), + sample_count * sizeof(int16_t)) == 0; } -bool media_start() +bool write_mono(const int16_t* pcm, std::size_t sample_count) { - if (s_task) - { - return !s_stop_requested; - } - if (!media_supported()) - { - return false; - } - - s_board = resolve_board(); - if (!s_board) - { - return false; - } - if (s_board->codec.open(kBitsPerSample, kI2sChannels, kSampleRate) != 0) - { - s_board = nullptr; - return false; - } - s_board->codec.setVolume(kDefaultVolume); - s_board->codec.setGain(kDefaultGainDb); - s_board->codec.setMute(false); - - s_stop_requested = false; - const BaseType_t rc = xTaskCreate(call_audio_task, - "rt_call_audio", - kTaskStackBytes, - nullptr, - kTaskPriority, - &s_task); - if (rc != pdPASS) - { - close_codec(); - s_task = nullptr; - return false; - } - std::printf("[Reticulum][CallAudio] media started codec2=1200\n"); - return true; + return s_board && pcm && sample_count > 0 && + s_board->codec.write(reinterpret_cast(const_cast(pcm)), + sample_count * sizeof(int16_t)) == 0; } -void media_stop() +uint8_t speaker_volume() { - s_stop_requested = true; + auto* board = resolve_board(); + return board ? board->getMessageToneVolume() : kDefaultVolume; +} + +void set_speaker_volume(uint8_t volume_percent) +{ + const uint8_t volume = volume_percent > 100U ? 100U : volume_percent; + auto* board = resolve_board(); + if (board) + { + board->setMessageToneVolume(volume); + } + if (s_board) + { + s_board->codec.setVolume(volume); + } } } // namespace @@ -373,12 +113,17 @@ void ensureReticulumCallAudioRuntimeRegistered() { return; } - ::platform::ui::reticulum_call::MediaHooks hooks{}; - hooks.is_supported = media_supported; - hooks.start = media_start; - hooks.stop = media_stop; - ::platform::ui::reticulum_call::set_media_hooks(hooks); - s_registered = true; + + ::platform::esp::common::reticulum_call_audio::Backend backend{}; + backend.is_supported = media_supported; + backend.open = open_audio; + backend.close = close_audio; + backend.read_mono = read_mono; + backend.write_mono = write_mono; + backend.speaker_volume = speaker_volume; + backend.set_speaker_volume = set_speaker_volume; + s_registered = + ::platform::esp::common::reticulum_call_audio::install_backend(backend); } } // namespace platform::esp::arduino_common @@ -389,19 +134,39 @@ namespace platform::esp::arduino_common { namespace { + bool s_registered = false; -bool media_supported() +bool unsupported() { return false; } -bool media_start() +bool open_unsupported(uint32_t) { return false; } -void media_stop() +void close_unsupported() +{ +} + +bool read_unsupported(int16_t*, std::size_t) +{ + return false; +} + +bool write_unsupported(const int16_t*, std::size_t) +{ + return false; +} + +uint8_t volume_unsupported() +{ + return 0; +} + +void set_volume_unsupported(uint8_t) { } @@ -413,12 +178,17 @@ void ensureReticulumCallAudioRuntimeRegistered() { return; } - ::platform::ui::reticulum_call::MediaHooks hooks{}; - hooks.is_supported = media_supported; - hooks.start = media_start; - hooks.stop = media_stop; - ::platform::ui::reticulum_call::set_media_hooks(hooks); - s_registered = true; + + ::platform::esp::common::reticulum_call_audio::Backend backend{}; + backend.is_supported = unsupported; + backend.open = open_unsupported; + backend.close = close_unsupported; + backend.read_mono = read_unsupported; + backend.write_mono = write_unsupported; + backend.speaker_volume = volume_unsupported; + backend.set_speaker_volume = set_volume_unsupported; + s_registered = + ::platform::esp::common::reticulum_call_audio::install_backend(backend); } } // namespace platform::esp::arduino_common diff --git a/platform/esp/arduino_common/src/screen_sleep.cpp b/platform/esp/arduino_common/src/screen_sleep.cpp index 7db9bbb6..17bda50b 100644 --- a/platform/esp/arduino_common/src/screen_sleep.cpp +++ b/platform/esp/arduino_common/src/screen_sleep.cpp @@ -403,6 +403,11 @@ void enterFromScreenSaver() updateUserActivity(); } +void wakeScreenForModal() +{ + enterFromScreenSaver(); +} + void disableScreenSleep() { bool hide_saver = false; diff --git a/platform/esp/common/include/platform/esp/common/reticulum_call_audio_engine.h b/platform/esp/common/include/platform/esp/common/reticulum_call_audio_engine.h new file mode 100644 index 00000000..d590e3db --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/reticulum_call_audio_engine.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace platform::esp::common::reticulum_call_audio +{ + +struct Backend +{ + bool (*is_supported)() = nullptr; + bool (*open)(uint32_t sample_rate_hz) = nullptr; + void (*close)() = nullptr; + bool (*read_mono)(int16_t* pcm, std::size_t sample_count) = nullptr; + bool (*write_mono)(const int16_t* pcm, std::size_t sample_count) = nullptr; + uint8_t (*speaker_volume)() = nullptr; + void (*set_speaker_volume)(uint8_t volume_percent) = nullptr; +}; + +bool install_backend(const Backend& backend); + +} // namespace platform::esp::common::reticulum_call_audio diff --git a/platform/esp/common/include/platform/esp/common/reticulum_crypto_compat.h b/platform/esp/common/include/platform/esp/common/reticulum_crypto_compat.h new file mode 100644 index 00000000..710e484d --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/reticulum_crypto_compat.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#if defined(ARDUINO) + +#include +#include + +#else + +class Curve25519 +{ + public: + static void dh1(std::uint8_t public_key[32], std::uint8_t private_key[32]); + static bool dh2(std::uint8_t peer_key_and_secret[32], std::uint8_t private_key[32]); +}; + +class ReticulumRngCompat +{ + public: + void begin(const char* personalization) const; +}; + +extern ReticulumRngCompat RNG; + +#endif diff --git a/platform/esp/common/include/platform/esp/common/reticulum_runtime_compat.h b/platform/esp/common/include/platform/esp/common/reticulum_runtime_compat.h new file mode 100644 index 00000000..1ca5f9ec --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/reticulum_runtime_compat.h @@ -0,0 +1,32 @@ +#pragma once + +#if defined(ARDUINO) + +#include + +#else + +#include "esp_random.h" + +#include +#include + +namespace platform::esp::common::reticulum_runtime +{ + +class Console +{ + public: + int printf(const char* format, ...); + void println(const char* text); +}; + +Console& console(); +std::uint32_t monotonic_millis(); + +} // namespace platform::esp::common::reticulum_runtime + +#define Serial (::platform::esp::common::reticulum_runtime::console()) +#define millis() (::platform::esp::common::reticulum_runtime::monotonic_millis()) + +#endif diff --git a/platform/esp/common/library.json b/platform/esp/common/library.json new file mode 100644 index 00000000..ca3dc4fc --- /dev/null +++ b/platform/esp/common/library.json @@ -0,0 +1,19 @@ +{ + "name": "esp_common", + "version": "0.1.0", + "frameworks": [ + "arduino" + ], + "build": { + "includeDir": "include", + "srcDir": "src" + }, + "dependencies": [ + { + "name": "core_sys" + }, + { + "name": "trail_mate_codec2" + } + ] +} diff --git a/platform/esp/common/src/reticulum_call_audio_engine.cpp b/platform/esp/common/src/reticulum_call_audio_engine.cpp new file mode 100644 index 00000000..28b2548d --- /dev/null +++ b/platform/esp/common/src/reticulum_call_audio_engine.cpp @@ -0,0 +1,417 @@ +#include "platform/esp/common/reticulum_call_audio_engine.h" + +#include "chat/infra/reticulum/audio_call_wire.h" +#include "platform/ui/reticulum_call_runtime.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace platform::esp::common::reticulum_call_audio +{ +namespace +{ + +using ::chat::reticulum::audio_call::Codec2Mode; +using ::chat::reticulum::audio_call::DecodedPayload; + +constexpr uint32_t kSampleRateHz = 8000; +constexpr uint8_t kMaxCodecFramesPerPacket = 13; +constexpr uint8_t kJitterMinFrames = 3; +constexpr uint8_t kJitterMaxFrames = 16; +constexpr float kTxPcmGain = 1.5f; +constexpr float kRxPcmGain = 1.0f; +constexpr uint32_t kTaskStackBytes = 18 * 1024; +constexpr UBaseType_t kTaskPriority = tskIDLE_PRIORITY + 2; +constexpr uint32_t kStopTimeoutMs = 750; + +Backend s_backend{}; +TaskHandle_t s_task = nullptr; +volatile bool s_stop_requested = false; +bool s_backend_open = false; + +struct CodecSelection +{ + int codec2_mode = CODEC2_MODE_3200; + Codec2Mode wire_mode = Codec2Mode::Mode3200; + uint32_t target_packet_ms = 200; +}; + +CodecSelection codec_selection( + const ::platform::ui::reticulum_call::Snapshot& snapshot) +{ + CodecSelection selection{}; + switch (snapshot.codec2_mode) + { + case ::platform::ui::reticulum_call::Codec2Mode::Mode700C: + selection.codec2_mode = CODEC2_MODE_700C; + selection.wire_mode = Codec2Mode::Mode700C; + selection.target_packet_ms = 400; + break; + case ::platform::ui::reticulum_call::Codec2Mode::Mode1200: + selection.codec2_mode = CODEC2_MODE_1200; + selection.wire_mode = Codec2Mode::Mode1200; + selection.target_packet_ms = 520; + break; + case ::platform::ui::reticulum_call::Codec2Mode::Mode1600: + selection.codec2_mode = CODEC2_MODE_1600; + selection.wire_mode = Codec2Mode::Mode1600; + selection.target_packet_ms = 320; + break; + case ::platform::ui::reticulum_call::Codec2Mode::Mode3200: + break; + } + return selection; +} + +uint32_t now_ms() +{ + return static_cast(pdTICKS_TO_MS(xTaskGetTickCount())); +} + +void* audio_alloc(std::size_t bytes) +{ + return heap_caps_malloc_prefer(bytes, + 2, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); +} + +void audio_free(void* ptr) +{ + if (ptr) + { + heap_caps_free(ptr); + } +} + +bool backend_complete(const Backend& backend) +{ + return backend.is_supported && backend.open && backend.close && + backend.read_mono && backend.write_mono && backend.speaker_volume && + backend.set_speaker_volume; +} + +bool media_supported() +{ + return backend_complete(s_backend) && s_backend.is_supported(); +} + +uint8_t speaker_volume() +{ + return backend_complete(s_backend) ? s_backend.speaker_volume() : 0; +} + +void set_speaker_volume(uint8_t volume_percent) +{ + if (backend_complete(s_backend)) + { + s_backend.set_speaker_volume(volume_percent > 100U ? 100U : volume_percent); + } +} + +int16_t clamp_sample(int32_t sample) +{ + if (sample > 32767) + { + return 32767; + } + if (sample < -32768) + { + return -32768; + } + return static_cast(sample); +} + +void close_backend() +{ + if (s_backend_open && s_backend.close) + { + s_backend.close(); + } + s_backend_open = false; +} + +void free_buffers(int16_t* pcm_in, + int16_t* pcm_out, + int16_t* silence, + uint8_t* codec_frame, + uint8_t* tx_frames, + uint8_t* rx_frames, + uint8_t* packet_buf) +{ + audio_free(pcm_in); + audio_free(pcm_out); + audio_free(silence); + audio_free(codec_frame); + audio_free(tx_frames); + audio_free(rx_frames); + audio_free(packet_buf); +} + +void fail_media_start(CODEC2* codec2_state, + int16_t* pcm_in, + int16_t* pcm_out, + int16_t* silence, + uint8_t* codec_frame, + uint8_t* tx_frames, + uint8_t* rx_frames, + uint8_t* packet_buf) +{ + free_buffers(pcm_in, + pcm_out, + silence, + codec_frame, + tx_frames, + rx_frames, + packet_buf); + if (codec2_state) + { + codec2_destroy(codec2_state); + } + close_backend(); + s_task = nullptr; + ::platform::ui::reticulum_call::notify_media_failed(); + vTaskDelete(nullptr); +} + +void call_audio_task(void*) +{ + const auto initial_snapshot = ::platform::ui::reticulum_call::snapshot(); + const CodecSelection selection = codec_selection(initial_snapshot); + CODEC2* codec2_state = codec2_create(selection.codec2_mode); + if (!codec2_state) + { + fail_media_start(nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr); + return; + } + codec2_set_lpc_post_filter(codec2_state, 1, 0, 0.8f, 0.2f); + + const int samples_per_frame = codec2_samples_per_frame(codec2_state); + const int bytes_per_frame = codec2_bytes_per_frame(codec2_state); + const uint32_t frame_interval_ms = + static_cast((samples_per_frame * 1000U) / kSampleRateHz); + const uint8_t codec_frames_per_packet = static_cast( + std::min(kMaxCodecFramesPerPacket, + std::max(1, + selection.target_packet_ms / + frame_interval_ms))); + + auto* pcm_in = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); + auto* pcm_out = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); + auto* silence = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); + auto* codec_frame = static_cast(audio_alloc(bytes_per_frame)); + auto* tx_frames = + static_cast(audio_alloc(bytes_per_frame * kMaxCodecFramesPerPacket)); + auto* rx_frames = + static_cast(audio_alloc(bytes_per_frame * kJitterMaxFrames)); + auto* packet_buf = + static_cast(audio_alloc(::platform::ui::reticulum_call::kAudioPacketMaxLen)); + + if (!pcm_in || !pcm_out || !silence || !codec_frame || !tx_frames || !rx_frames || + !packet_buf) + { + fail_media_start(codec2_state, + pcm_in, + pcm_out, + silence, + codec_frame, + tx_frames, + rx_frames, + packet_buf); + return; + } + std::memset(silence, 0, samples_per_frame * sizeof(int16_t)); + + uint8_t tx_frame_count = 0; + uint8_t rx_frame_count = 0; + uint8_t rx_head = 0; + uint8_t rx_tail = 0; + uint32_t last_play_ms = now_ms(); + + while (!s_stop_requested && ::platform::ui::reticulum_call::media_should_run()) + { + const auto snapshot = ::platform::ui::reticulum_call::snapshot(); + + ::platform::ui::reticulum_call::AudioPacket inbound{}; + uint8_t inbound_drains = 0; + while (inbound_drains < 2 && + ::platform::ui::reticulum_call::dequeue_inbound_audio(&inbound)) + { + DecodedPayload decoded{}; + if (::chat::reticulum::audio_call::decodePayload(inbound.data, + inbound.len, + &decoded) && + decoded.mode == selection.wire_mode && decoded.encoded && + decoded.encoded_len >= static_cast(bytes_per_frame)) + { + const std::size_t frames = + decoded.encoded_len / static_cast(bytes_per_frame); + for (std::size_t index = 0; index < frames; ++index) + { + if (rx_frame_count >= kJitterMaxFrames) + { + break; + } + uint8_t* dst = rx_frames + (rx_tail * bytes_per_frame); + std::memcpy(dst, + decoded.encoded + (index * bytes_per_frame), + bytes_per_frame); + rx_tail = static_cast((rx_tail + 1U) % kJitterMaxFrames); + ++rx_frame_count; + } + } + ++inbound_drains; + } + + if (s_backend.read_mono(pcm_in, static_cast(samples_per_frame))) + { + for (int index = 0; index < samples_per_frame; ++index) + { + pcm_in[index] = + clamp_sample(static_cast(pcm_in[index] * kTxPcmGain)); + } + codec2_encode(codec2_state, codec_frame, pcm_in); + std::memcpy(tx_frames + (tx_frame_count * bytes_per_frame), + codec_frame, + bytes_per_frame); + ++tx_frame_count; + + if (tx_frame_count >= codec_frames_per_packet) + { + std::size_t packet_len = + ::platform::ui::reticulum_call::kAudioPacketMaxLen; + if (::chat::reticulum::audio_call::encodePayload( + selection.wire_mode, + tx_frames, + bytes_per_frame * tx_frame_count, + packet_buf, + &packet_len)) + { + (void)::platform::ui::reticulum_call::enqueue_outbound_audio( + snapshot.link_id, + packet_buf, + packet_len); + } + tx_frame_count = 0; + } + } + + const uint32_t current_ms = now_ms(); + if (current_ms - last_play_ms >= frame_interval_ms) + { + last_play_ms = current_ms; + const int16_t* playback = silence; + if (rx_frame_count >= kJitterMinFrames) + { + const uint8_t* frame = rx_frames + (rx_head * bytes_per_frame); + codec2_decode(codec2_state, pcm_out, const_cast(frame)); + rx_head = static_cast((rx_head + 1U) % kJitterMaxFrames); + --rx_frame_count; + for (int index = 0; index < samples_per_frame; ++index) + { + pcm_out[index] = + clamp_sample(static_cast(pcm_out[index] * kRxPcmGain)); + } + playback = pcm_out; + } + (void)s_backend.write_mono(playback, + static_cast(samples_per_frame)); + } + + vTaskDelay(pdMS_TO_TICKS(2)); + } + + free_buffers(pcm_in, + pcm_out, + silence, + codec_frame, + tx_frames, + rx_frames, + packet_buf); + codec2_destroy(codec2_state); + close_backend(); + s_stop_requested = false; + s_task = nullptr; + vTaskDelete(nullptr); +} + +bool media_start() +{ + if (s_task) + { + return !s_stop_requested; + } + if (!media_supported() || !s_backend.open(kSampleRateHz)) + { + return false; + } + + s_backend_open = true; + s_stop_requested = false; + const BaseType_t rc = xTaskCreate(call_audio_task, + "rt_call_audio", + kTaskStackBytes, + nullptr, + kTaskPriority, + &s_task); + if (rc != pdPASS) + { + close_backend(); + s_task = nullptr; + return false; + } + const auto snapshot = ::platform::ui::reticulum_call::snapshot(); + const CodecSelection selection = codec_selection(snapshot); + std::printf("[Reticulum][CallAudio] media started codec2_mode=%d wire=%u\n", + selection.codec2_mode, + static_cast(snapshot.wire_profile)); + return true; +} + +void media_stop() +{ + s_stop_requested = true; + const TaskHandle_t task = s_task; + if (!task || xTaskGetCurrentTaskHandle() == task) + { + return; + } + + const TickType_t started = xTaskGetTickCount(); + while (s_task && pdTICKS_TO_MS(xTaskGetTickCount() - started) < kStopTimeoutMs) + { + vTaskDelay(pdMS_TO_TICKS(5)); + } + if (s_task) + { + std::printf("[Reticulum][CallAudio] stop timeout\n"); + } +} + +} // namespace + +bool install_backend(const Backend& backend) +{ + if (!backend_complete(backend) || s_task || s_backend_open) + { + return false; + } + + s_backend = backend; + ::platform::ui::reticulum_call::MediaHooks hooks{}; + hooks.is_supported = media_supported; + hooks.start = media_start; + hooks.stop = media_stop; + hooks.speaker_volume = speaker_volume; + hooks.set_speaker_volume = set_speaker_volume; + ::platform::ui::reticulum_call::set_media_hooks(hooks); + return true; +} + +} // namespace platform::esp::common::reticulum_call_audio diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/reticulum_call_runtime_support.h b/platform/esp/idf_common/include/platform/esp/idf_common/reticulum_call_runtime_support.h new file mode 100644 index 00000000..3d9cff8d --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/reticulum_call_runtime_support.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace platform::esp::idf_common::reticulum_call_support +{ + +void ensure_registered(); +void set_speaker_volume(uint8_t volume_percent); +bool play_message_notification(); +bool play_incoming_notification(const volatile bool* stop_requested = nullptr); + +} // namespace platform::esp::idf_common::reticulum_call_support diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/startup_support.h b/platform/esp/idf_common/include/platform/esp/idf_common/startup_support.h index 85c34b0d..50d294ca 100644 --- a/platform/esp/idf_common/include/platform/esp/idf_common/startup_support.h +++ b/platform/esp/idf_common/include/platform/esp/idf_common/startup_support.h @@ -3,6 +3,7 @@ namespace platform::esp::idf_common::startup_support { +void initializeClockProviders(); void logStartupBanner(const char* tag); } // namespace platform::esp::idf_common::startup_support diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/usb_console_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/usb_console_runtime.h new file mode 100644 index 00000000..2f3c5c1a --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/usb_console_runtime.h @@ -0,0 +1,9 @@ +#pragma once + +namespace platform::esp::idf_common::usb_console +{ + +bool ensure_started(); +bool is_started(); + +} // namespace platform::esp::idf_common::usb_console diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/wireless_companion/c6_companion.h b/platform/esp/idf_common/include/platform/esp/idf_common/wireless_companion/c6_companion.h index d0f4507a..165de20b 100644 --- a/platform/esp/idf_common/include/platform/esp/idf_common/wireless_companion/c6_companion.h +++ b/platform/esp/idf_common/include/platform/esp/idf_common/wireless_companion/c6_companion.h @@ -149,6 +149,38 @@ struct C6CompanionStatus const char* detail = "unsupported"; }; +enum class WifiTcpState : uint8_t +{ + Unsupported = 0, + Disconnected = 1, + Opening = 2, + Connected = 3, + Closing = 4, + Error = 5, +}; + +struct WifiTcpStatus +{ + WifiTcpState state = WifiTcpState::Unsupported; + uint8_t connection_id = 0; + uint16_t error_code = 0; + size_t buffered_bytes = 0; + uint32_t received_bytes = 0; + uint32_t transmitted_bytes = 0; + const char* detail = "unsupported"; +}; + +class WifiTcpTransport +{ + public: + virtual bool openTcp(const char* host, uint16_t port) = 0; + virtual bool writeTcp(const uint8_t* data, size_t len) = 0; + virtual size_t readTcp(uint8_t* out, size_t max_len) = 0; + virtual void closeTcp() = 0; + virtual WifiTcpStatus tcpStatus() const = 0; + virtual ~WifiTcpTransport() = default; +}; + class WirelessCompanion { public: @@ -167,6 +199,7 @@ class WirelessCompanion }; WirelessCompanion& c6_companion(); +WifiTcpTransport& c6_wifi_tcp_transport(); bool ensure_c6_companion_started(); bool enter_c6_companion_download_mode(); C6CompanionStatus get_c6_companion_status(); diff --git a/platform/esp/idf_common/src/c6_companion_runtime.cpp b/platform/esp/idf_common/src/c6_companion_runtime.cpp index 4ce32c0f..5f92d8f8 100644 --- a/platform/esp/idf_common/src/c6_companion_runtime.cpp +++ b/platform/esp/idf_common/src/c6_companion_runtime.cpp @@ -3,6 +3,7 @@ #include "hostlink/c6/c6_frame_codec.h" #include "hostlink/c6/c6_protocol.h" #include "platform/ui/settings_store.h" +#include "platform/ui/wifi_runtime.h" #include #include @@ -54,13 +55,10 @@ constexpr uint32_t kRequestedFeatures = TM_C6_FEATURE_BLE_MESHTASTIC | TM_C6_FEATURE_WIFI_STA | TM_C6_FEATURE_WIFI_AP | TM_C6_FEATURE_DIAG_LOG | - TM_C6_FEATURE_HOSTLINK_PING; + TM_C6_FEATURE_HOSTLINK_PING | + TM_C6_FEATURE_WIFI_TCP_PROXY; constexpr uint32_t kDefaultConfigSequence = 1; constexpr const char* kSettingsNamespace = "c6_companion"; -constexpr const char* kSharedSettingsNamespace = "settings"; -constexpr const char* kWifiEnabledKey = "wifi_enabled"; -constexpr const char* kWifiSsidKey = "wifi_ssid"; -constexpr const char* kWifiPasswordKey = "wifi_password"; constexpr uint32_t kNetworkTimeMinValidEpochSeconds = 1577836800; // 2020-01-01 UTC bool board_has_c6_companion() @@ -327,8 +325,12 @@ WifiCompanionConfig load_wifi_config_from_p4_settings() { WifiCompanionConfig config{}; #if defined(ESP_PLATFORM) + ::platform::ui::wifi::Config shared_wifi{}; + (void)::platform::ui::wifi::load_config(shared_wifi); config.enabled = - ::platform::ui::settings_store::get_bool(kSettingsNamespace, "wifi_enabled", config.enabled); + ::platform::ui::settings_store::get_bool(kSettingsNamespace, + "wifi_enabled", + shared_wifi.enabled); config.sta_enabled = ::platform::ui::settings_store::get_bool(kSettingsNamespace, "wifi_sta_enabled", config.enabled); config.ap_enabled = @@ -337,17 +339,27 @@ WifiCompanionConfig load_wifi_config_from_p4_settings() ::platform::ui::settings_store::get_bool(kSettingsNamespace, "wifi_persist_credentials", false); std::string value; - if (::platform::ui::settings_store::get_string(kSettingsNamespace, "wifi_sta_ssid", value) || - ::platform::ui::settings_store::get_string(kSharedSettingsNamespace, kWifiSsidKey, value)) + if (::platform::ui::settings_store::get_string(kSettingsNamespace, + "wifi_sta_ssid", + value)) { copy_text(config.sta_ssid, value); } + else + { + copy_text(config.sta_ssid, shared_wifi.ssid); + } value.clear(); - if (::platform::ui::settings_store::get_string(kSettingsNamespace, "wifi_sta_password", value) || - ::platform::ui::settings_store::get_string(kSharedSettingsNamespace, kWifiPasswordKey, value)) + if (::platform::ui::settings_store::get_string(kSettingsNamespace, + "wifi_sta_password", + value)) { copy_text(config.sta_password, value); } + else + { + copy_text(config.sta_password, shared_wifi.password); + } value.clear(); if (::platform::ui::settings_store::get_string(kSettingsNamespace, "wifi_ap_ssid", value)) { @@ -359,9 +371,7 @@ WifiCompanionConfig load_wifi_config_from_p4_settings() copy_text(config.ap_password, value); } - const bool shared_wifi_enabled = - ::platform::ui::settings_store::get_bool(kSharedSettingsNamespace, kWifiEnabledKey, false); - if (shared_wifi_enabled && config.sta_ssid[0] != '\0') + if (shared_wifi.enabled && config.sta_ssid[0] != '\0') { config.enabled = true; config.sta_enabled = true; @@ -409,7 +419,7 @@ uint32_t requested_features_for(const BleCompanionConfig& ble, { if (wifi.sta_enabled) { - features |= TM_C6_FEATURE_WIFI_STA; + features |= TM_C6_FEATURE_WIFI_STA | TM_C6_FEATURE_WIFI_TCP_PROXY; } if (wifi.ap_enabled) { @@ -900,7 +910,7 @@ class SdioC6Transport final : public C6Transport }; #endif -class C6CompanionRuntime final : public WirelessCompanion +class C6CompanionRuntime final : public WirelessCompanion, public WifiTcpTransport { public: bool begin() override @@ -933,6 +943,7 @@ class C6CompanionRuntime final : public WirelessCompanion status_.wifi_ipv4_addr = 0; status_.wifi_scan_result_count = 0; status_.wifi_ssid[0] = '\0'; + reset_tcp_state(WifiTcpState::Disconnected, "not_connected"); for (auto& result : status_.wifi_scan_results) { result = WifiScanResult{}; @@ -1140,6 +1151,115 @@ class C6CompanionRuntime final : public WirelessCompanion return sent; } + bool openTcp(const char* host, uint16_t port) override + { + if (!host || host[0] == '\0' || std::strlen(host) >= TM_C6_WIFI_TCP_HOST_LEN || port == 0) + { + set_tcp_state(WifiTcpState::Error, TM_C6_ERROR_INTERNAL, "invalid_endpoint"); + return false; + } + if (!status_.started) + { + (void)begin(); + } + if (!status_.present || !status_.wifi_connected || + (status_.supported_features & TM_C6_FEATURE_WIFI_TCP_PROXY) == 0) + { + set_tcp_state(WifiTcpState::Disconnected, TM_C6_ERROR_NOT_CONNECTED, "wifi_not_ready"); + return false; + } + + reset_tcp_state(WifiTcpState::Disconnected, "opening_reset"); + tcp_status_.connection_id = next_tcp_connection_id_++; + if (next_tcp_connection_id_ == 0) + { + next_tcp_connection_id_ = 1; + } + + tm_c6_wifi_tcp_header_t header{}; + header.operation = TM_C6_WIFI_TCP_OPEN; + header.connection_id = tcp_status_.connection_id; + header.port = port; + copy_text(header.host, host); + if (!send_wifi_tcp_command(header, nullptr, 0)) + { + set_tcp_state(WifiTcpState::Error, TM_C6_ERROR_INTERNAL, "open_send_failed"); + return false; + } + set_tcp_state(WifiTcpState::Opening, TM_C6_OK, "opening"); + return true; + } + + bool writeTcp(const uint8_t* data, size_t len) override + { + if (tcp_status_.state != WifiTcpState::Connected || (data == nullptr && len != 0) || + len == 0) + { + return false; + } + + size_t offset = 0; + while (offset < len) + { + const size_t chunk = std::min(TM_C6_WIFI_TCP_PAYLOAD_MAX, len - offset); + tm_c6_wifi_tcp_header_t header{}; + header.operation = TM_C6_WIFI_TCP_WRITE; + header.connection_id = tcp_status_.connection_id; + header.payload_len = static_cast(chunk); + if (!send_wifi_tcp_command(header, data + offset, chunk)) + { + set_tcp_state(WifiTcpState::Error, TM_C6_ERROR_INTERNAL, "write_send_failed"); + return false; + } + offset += chunk; + } + tcp_status_.transmitted_bytes += static_cast(len); + return true; + } + + size_t readTcp(uint8_t* out, size_t max_len) override + { + if (!out || max_len == 0) + { + return 0; + } + poll(); + + const size_t read_len = std::min(max_len, tcp_rx_size_); + for (size_t i = 0; i < read_len; ++i) + { + out[i] = tcp_rx_buffer_[tcp_rx_tail_]; + tcp_rx_tail_ = (tcp_rx_tail_ + 1) % tcp_rx_buffer_.size(); + } + tcp_rx_size_ -= read_len; + tcp_status_.buffered_bytes = tcp_rx_size_; + return read_len; + } + + void closeTcp() override + { + if (tcp_status_.state == WifiTcpState::Disconnected || + tcp_status_.state == WifiTcpState::Unsupported) + { + return; + } + + tm_c6_wifi_tcp_header_t header{}; + header.operation = TM_C6_WIFI_TCP_CLOSE; + header.connection_id = tcp_status_.connection_id; + if (status_.present && send_wifi_tcp_command(header, nullptr, 0)) + { + set_tcp_state(WifiTcpState::Closing, TM_C6_OK, "closing"); + return; + } + reset_tcp_state(WifiTcpState::Disconnected, "closed_locally"); + } + + WifiTcpStatus tcpStatus() const override + { + return tcp_status_; + } + void poll() override { if (!status_.started || !status_.present) @@ -1151,9 +1271,13 @@ class C6CompanionRuntime final : public WirelessCompanion { return; } - hostlink::c6::Frame frame{}; - if (receive_frame(frame, 0, false)) + for (size_t i = 0; i < 8; ++i) { + hostlink::c6::Frame frame{}; + if (!receive_frame(frame, 0, false)) + { + break; + } handle_async_frame(frame); } #endif @@ -1292,6 +1416,69 @@ class C6CompanionRuntime final : public WirelessCompanion #endif } + bool send_wifi_tcp_command(const tm_c6_wifi_tcp_header_t& header, + const uint8_t* data, + size_t len) + { + if (len > TM_C6_WIFI_TCP_PAYLOAD_MAX || (data == nullptr && len != 0)) + { + return false; + } + std::memcpy(wifi_tcp_tx_buffer_.data(), &header, sizeof(header)); + if (len > 0) + { + std::memcpy(wifi_tcp_tx_buffer_.data() + sizeof(header), data, len); + } + return send_frame(TM_C6_FRAME_WIFI_DATA, + TM_C6_CH_WIFI_DATA, + TM_C6_FLAG_ACK_REQUIRED, + 0, + wifi_tcp_tx_buffer_.data(), + sizeof(header) + len); + } + + void reset_tcp_rx() + { + tcp_rx_head_ = 0; + tcp_rx_tail_ = 0; + tcp_rx_size_ = 0; + tcp_status_.buffered_bytes = 0; + } + + void set_tcp_state(WifiTcpState state, uint16_t error_code, const char* detail) + { + tcp_status_.state = state; + tcp_status_.error_code = error_code; + tcp_status_.detail = detail; + } + + void reset_tcp_state(WifiTcpState state, const char* detail) + { + const uint8_t connection_id = tcp_status_.connection_id; + tcp_status_ = WifiTcpStatus{}; + tcp_status_.state = state; + tcp_status_.connection_id = connection_id; + tcp_status_.detail = detail; + reset_tcp_rx(); + } + + bool append_tcp_rx(const uint8_t* data, size_t len) + { + if ((!data && len != 0) || len > tcp_rx_buffer_.size() - tcp_rx_size_) + { + return false; + } + for (size_t i = 0; i < len; ++i) + { + tcp_rx_buffer_[tcp_rx_head_] = data[i]; + tcp_rx_head_ = (tcp_rx_head_ + 1) % tcp_rx_buffer_.size(); + } + tcp_rx_size_ += len; + tcp_status_.buffered_bytes = tcp_rx_size_; + tcp_status_.received_bytes += static_cast(len); + return true; + } + bool send_hello() { uint8_t payload[sizeof(tm_c6_hello_t)] = {}; @@ -1505,6 +1692,11 @@ class C6CompanionRuntime final : public WirelessCompanion frame.payload.size() == sizeof(tm_c6_error_t)) { const auto* error = reinterpret_cast(frame.payload.data()); + if (error->related_channel == TM_C6_CH_WIFI_DATA) + { + set_tcp_state(WifiTcpState::Error, error->error_code, "c6_wifi_tcp_error"); + return; + } status_.config_error = error->error_code; set_detail(status_, CompanionState::Error, "c6_error_frame"); return; @@ -1592,6 +1784,7 @@ class C6CompanionRuntime final : public WirelessCompanion case TM_C6_WIFI_EVENT_STOPPED: status_.wifi_connected = false; status_.wifi_scanning = false; + reset_tcp_state(WifiTcpState::Disconnected, "wifi_disconnected"); break; case TM_C6_WIFI_EVENT_ERROR: status_.wifi_scanning = false; @@ -1601,6 +1794,45 @@ class C6CompanionRuntime final : public WirelessCompanion } return; } + if (frame.frame_type == TM_C6_FRAME_WIFI_DATA && + frame.channel == TM_C6_CH_WIFI_DATA && + frame.payload.size() >= sizeof(tm_c6_wifi_tcp_header_t)) + { + tm_c6_wifi_tcp_header_t header{}; + std::memcpy(&header, frame.payload.data(), sizeof(header)); + const size_t data_len = frame.payload.size() - sizeof(header); + if (header.payload_len != data_len || + header.connection_id != tcp_status_.connection_id) + { + return; + } + + switch (header.operation) + { + case TM_C6_WIFI_TCP_OPENED: + set_tcp_state(WifiTcpState::Connected, TM_C6_OK, "connected"); + break; + case TM_C6_WIFI_TCP_DATA: + if (!append_tcp_rx(frame.payload.data() + sizeof(header), data_len)) + { + set_tcp_state(WifiTcpState::Error, TM_C6_ERROR_QUEUE_FULL, "rx_overflow"); + tm_c6_wifi_tcp_header_t close_header{}; + close_header.operation = TM_C6_WIFI_TCP_CLOSE; + close_header.connection_id = tcp_status_.connection_id; + (void)send_wifi_tcp_command(close_header, nullptr, 0); + } + break; + case TM_C6_WIFI_TCP_CLOSED: + set_tcp_state(WifiTcpState::Disconnected, TM_C6_OK, "remote_closed"); + break; + case TM_C6_WIFI_TCP_ERROR: + set_tcp_state(WifiTcpState::Error, header.error_code, "remote_error"); + break; + default: + break; + } + return; + } if (frame.frame_type == TM_C6_FRAME_LOG) { #if defined(ESP_PLATFORM) @@ -1771,6 +2003,13 @@ class C6CompanionRuntime final : public WirelessCompanion BleCompanionConfig ble_config_{}; EspNowCompanionConfig espnow_config_{}; WifiCompanionConfig wifi_config_{}; + WifiTcpStatus tcp_status_{}; + uint8_t next_tcp_connection_id_ = 1; + std::array wifi_tcp_tx_buffer_{}; + std::array tcp_rx_buffer_{}; + size_t tcp_rx_head_ = 0; + size_t tcp_rx_tail_ = 0; + size_t tcp_rx_size_ = 0; #if defined(ESP_PLATFORM) std::unique_ptr transport_; #endif @@ -1785,6 +2024,11 @@ WirelessCompanion& c6_companion() return s_c6_companion; } +WifiTcpTransport& c6_wifi_tcp_transport() +{ + return s_c6_companion; +} + bool ensure_c6_companion_started() { return c6_companion().begin(); diff --git a/platform/esp/idf_common/src/chat_blob_store_io.cpp b/platform/esp/idf_common/src/chat_blob_store_io.cpp new file mode 100644 index 00000000..12b23512 --- /dev/null +++ b/platform/esp/idf_common/src/chat_blob_store_io.cpp @@ -0,0 +1,284 @@ +#include "platform/esp/arduino_common/src/chat/internal/blob_store_io.h" + +#if defined(ESP_PLATFORM) && !defined(ARDUINO) + +#include "platform/esp/idf_common/bsp_runtime.h" +#include "platform/ui/settings_store.h" + +#include +#include +#include + +namespace chat::infra +{ +namespace +{ + +constexpr std::size_t kRawBlobReadChunkBytes = 256; + +bool valid_key(const char* value) +{ + return value && value[0] != '\0'; +} + +std::string absolute_sd_path(const char* path) +{ + if (!path || path[0] == '\0') + { + return {}; + } + + const char* mount = ::platform::esp::idf_common::bsp_runtime::sdcard_mount_point(); + std::string absolute = mount ? mount : ""; + if (absolute.empty()) + { + return {}; + } + if (absolute.back() == '/' && path[0] == '/') + { + absolute.pop_back(); + } + else if (absolute.back() != '/' && path[0] != '/') + { + absolute.push_back('/'); + } + absolute += path; + return absolute; +} + +} // namespace + +bool loadRawBlobFromSd(const char* path, std::vector& out, std::size_t max_len) +{ + out.clear(); + if (!valid_key(path) || + !::platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready()) + { + return false; + } + + const std::string absolute = absolute_sd_path(path); + FILE* file = std::fopen(absolute.c_str(), "rb"); + if (!file) + { + return false; + } + if (std::fseek(file, 0, SEEK_END) != 0) + { + std::fclose(file); + return false; + } + const long file_size = std::ftell(file); + if (file_size <= 0 || static_cast(file_size) > max_len || + std::fseek(file, 0, SEEK_SET) != 0) + { + std::fclose(file); + return false; + } + + out.reserve(static_cast(file_size)); + uint8_t buffer[kRawBlobReadChunkBytes] = {}; + std::size_t total = 0; + while (total < static_cast(file_size)) + { + const std::size_t chunk = + std::min(sizeof(buffer), static_cast(file_size) - total); + const std::size_t read = std::fread(buffer, 1, chunk, file); + if (read != chunk) + { + std::fclose(file); + out.clear(); + return false; + } + out.insert(out.end(), buffer, buffer + read); + total += read; + } + std::fclose(file); + return true; +} + +bool saveRawBlobToSd(const char* path, const uint8_t* data, std::size_t len) +{ + if (!valid_key(path) || (!data && len != 0) || + !::platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready()) + { + return false; + } + + const std::string absolute = absolute_sd_path(path); + const std::string temporary = absolute + ".tmp"; + std::remove(temporary.c_str()); + + FILE* file = std::fopen(temporary.c_str(), "wb"); + if (!file) + { + return false; + } + const std::size_t written = len == 0 ? 0 : std::fwrite(data, 1, len, file); + const int close_status = std::fclose(file); + if (written != len || close_status != 0) + { + std::remove(temporary.c_str()); + return false; + } + + std::remove(absolute.c_str()); + if (std::rename(temporary.c_str(), absolute.c_str()) != 0) + { + std::remove(temporary.c_str()); + return false; + } + return true; +} + +bool loadRawBlobFromPreferences(const char* ns, + const char* key, + std::vector& out) +{ + out.clear(); + return valid_key(ns) && valid_key(key) && + ::platform::ui::settings_store::get_blob(ns, key, out) && + !out.empty(); +} + +bool saveRawBlobToPreferences(const char* ns, + const char* key, + const uint8_t* data, + std::size_t len) +{ + return valid_key(ns) && valid_key(key) && + ::platform::ui::settings_store::put_blob(ns, key, data, len); +} + +void clearRawBlobFromPreferences(const char* ns, const char* key) +{ + clearPreferencesKeys(ns, key); +} + +bool loadRawBlobFromPreferencesWithMetadata(const char* ns, + const char* key, + const char* version_key, + const char* crc_key, + std::vector& out, + PreferencesBlobMetadata* meta) +{ + out.clear(); + if (meta) + { + *meta = PreferencesBlobMetadata{}; + } + if (!valid_key(ns) || !valid_key(key)) + { + return false; + } + + (void)::platform::ui::settings_store::get_blob(ns, key, out); + PreferencesBlobMetadata loaded{}; + loaded.len = out.size(); + if (valid_key(version_key)) + { + const uint32_t version = + ::platform::ui::settings_store::get_uint(ns, version_key, UINT32_MAX); + if (version != UINT32_MAX) + { + loaded.has_version = true; + loaded.version = static_cast(version); + } + } + if (valid_key(crc_key)) + { + const uint32_t crc = + ::platform::ui::settings_store::get_uint(ns, crc_key, UINT32_MAX); + if (crc != UINT32_MAX) + { + loaded.has_crc = true; + loaded.crc = crc; + } + } + if (meta) + { + *meta = loaded; + } + return true; +} + +bool saveRawBlobToPreferencesWithMetadata(const char* ns, + const char* key, + const char* version_key, + const char* crc_key, + const uint8_t* data, + std::size_t len, + const PreferencesBlobMetadata* meta, + bool retry_after_clear) +{ + if (!valid_key(ns) || !valid_key(key) || (!data && len != 0)) + { + return false; + } + + bool ok = ::platform::ui::settings_store::put_blob(ns, key, data, len); + if (!ok && retry_after_clear) + { + clearPreferencesKeys(ns, key); + ok = ::platform::ui::settings_store::put_blob(ns, key, data, len); + } + if (!ok) + { + return false; + } + + if (valid_key(version_key)) + { + if (meta && meta->has_version) + { + ::platform::ui::settings_store::put_uint(ns, version_key, meta->version); + } + else + { + clearPreferencesKeys(ns, version_key); + } + } + if (valid_key(crc_key)) + { + if (meta && meta->has_crc) + { + ::platform::ui::settings_store::put_uint(ns, crc_key, meta->crc); + } + else + { + clearPreferencesKeys(ns, crc_key); + } + } + return true; +} + +void clearPreferencesKeys(const char* ns, + const char* key_a, + const char* key_b, + const char* key_c) +{ + if (!valid_key(ns)) + { + return; + } + + const char* keys[3] = {}; + std::size_t count = 0; + if (valid_key(key_a)) + { + keys[count++] = key_a; + } + if (valid_key(key_b)) + { + keys[count++] = key_b; + } + if (valid_key(key_c)) + { + keys[count++] = key_c; + } + ::platform::ui::settings_store::remove_keys(ns, keys, count); +} + +} // namespace chat::infra + +#endif diff --git a/platform/esp/idf_common/src/platform_ui_device_runtime.cpp b/platform/esp/idf_common/src/platform_ui_device_runtime.cpp index c486aa4c..988ef0c8 100644 --- a/platform/esp/idf_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_device_runtime.cpp @@ -13,6 +13,7 @@ #include "platform/esp/common/build_info.h" #include "platform/esp/idf_common/bsp_runtime.h" #include "platform/esp/idf_common/gps_runtime.h" +#include "platform/esp/idf_common/reticulum_call_runtime_support.h" #if defined(TRAIL_MATE_ESP_BOARD_TAB5) #include "bsp/m5stack_tab5.h" @@ -173,10 +174,17 @@ uint8_t default_message_tone_volume() void set_message_tone_volume(uint8_t volume_percent) { - (void)volume_percent; + const uint8_t volume = volume_percent > 100U ? 100U : volume_percent; +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + ::boards::t_display_p4::TDisplayP4Board::instance().setMessageToneVolume(volume); +#endif + ::platform::esp::idf_common::reticulum_call_support::set_speaker_volume(volume); } -void play_message_tone() {} +void play_message_tone() +{ + (void)::platform::esp::idf_common::reticulum_call_support::play_message_notification(); +} bool sd_ready() { diff --git a/platform/esp/idf_common/src/platform_ui_reticulum_directory_runtime.cpp b/platform/esp/idf_common/src/platform_ui_reticulum_directory_runtime.cpp index 7ffa1bb6..24e8eafc 100644 --- a/platform/esp/idf_common/src/platform_ui_reticulum_directory_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_reticulum_directory_runtime.cpp @@ -1,16 +1,69 @@ #include "platform/ui/reticulum_directory_runtime.h" #include "platform/ui/reticulum_page_runtime.h" +#include "chat/ports/i_mesh_peer_directory.h" +#include "platform/esp/idf_common/bsp_runtime.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#include +#include #include #include +#include +#include +#include namespace platform::ui::reticulum_directory { namespace { -constexpr const char* kAnnouncesPath = "/trailmate/reticulum/announces.tsv"; -constexpr const char* kLxmfAddressesPath = "/trailmate/reticulum/lxmf_addresses.tsv"; +constexpr const char* kReticulumDir = "/trailmate/reticulum"; +constexpr const char* kAnnouncesPath = "/trailmate/reticulum/announces.bin"; +constexpr const char* kLxmfAddressesPath = "/mesh/peers.bin"; +constexpr uint32_t kAnnounceMagic = 0x52414E31UL; // RAN1 +constexpr uint16_t kAnnounceVersion = 1; +constexpr std::size_t kMaxAnnounceRecords = 128; +constexpr UBaseType_t kAnnounceQueueDepth = 16; +constexpr uint32_t kAnnounceBatchDelayMs = 250; +constexpr uint32_t kAnnounceWorkerStackBytes = 5U * 1024U; + +struct PersistedAnnounce +{ + uint8_t destination_hash[kReticulumHashSize] = {}; + uint8_t identity_hash[kReticulumHashSize] = {}; + uint8_t aspect = 0; + uint8_t source = 0; + uint8_t hops = 0xFF; + uint8_t flags = 0; + uint32_t first_seen_s = 0; + uint32_t last_seen_s = 0; + char display_name[kReticulumDisplayNameSize] = {}; +}; + +struct AnnounceFileHeader +{ + uint32_t magic = kAnnounceMagic; + uint16_t version = kAnnounceVersion; + uint16_t record_size = sizeof(PersistedAnnounce); + uint32_t count = 0; + uint32_t checksum = 0; +}; + +enum class AnnounceLoadResult : uint8_t +{ + Loaded, + Missing, + Unavailable, + IoError, +}; + +chat::IMeshPeerDirectory* s_mesh_peer_directory = nullptr; +QueueHandle_t s_announce_queue = nullptr; +TaskHandle_t s_announce_worker = nullptr; void copy_text(char* out, std::size_t out_len, const char* text) { @@ -27,11 +80,630 @@ void set_status(Status& out, const char* message, const char* detail) copy_text(out.detail, sizeof(out.detail), detail); } -Status unsupported(const char* message, const char* detail) +bool zero_hash(const uint8_t* hash, std::size_t len) +{ + if (!hash) + { + return true; + } + for (std::size_t index = 0; index < len; ++index) + { + if (hash[index] != 0) + { + return false; + } + } + return true; +} + +bool hash_equal(const uint8_t* lhs, const uint8_t* rhs, std::size_t len) +{ + return lhs && rhs && std::memcmp(lhs, rhs, len) == 0; +} + +uint32_t fnv1a32(const void* data, std::size_t len) +{ + const auto* bytes = static_cast(data); + uint32_t hash = 2166136261UL; + for (std::size_t index = 0; index < len; ++index) + { + hash ^= bytes[index]; + hash *= 16777619UL; + } + return hash; +} + +bool sd_ready() +{ + return platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready(); +} + +std::string make_sd_path(const char* relative) +{ + const char* mount = + platform::esp::idf_common::bsp_runtime::sdcard_mount_point(); + std::string path = mount ? mount : ""; + if (path.empty() || !relative || !relative[0]) + { + return path; + } + if (path.back() == '/' && relative[0] == '/') + { + path.pop_back(); + } + else if (path.back() != '/' && relative[0] != '/') + { + path.push_back('/'); + } + path += relative; + return path; +} + +bool is_regular_file(const char* relative) +{ + if (!sd_ready()) + { + return false; + } + const std::string path = make_sd_path(relative); + struct stat info = {}; + return ::stat(path.c_str(), &info) == 0 && S_ISREG(info.st_mode); +} + +bool ensure_directory(const char* relative) +{ + const std::string path = make_sd_path(relative); + struct stat info = {}; + if (::stat(path.c_str(), &info) == 0) + { + return S_ISDIR(info.st_mode); + } + if (::mkdir(path.c_str(), 0775) == 0) + { + return true; + } + return ::stat(path.c_str(), &info) == 0 && S_ISDIR(info.st_mode); +} + +bool ensure_reticulum_directory() +{ + return sd_ready() && ensure_directory("/trailmate") && + ensure_directory(kReticulumDir); +} + +AnnounceLoadResult load_persisted_announces( + std::vector& out) +{ + out.clear(); + if (!sd_ready()) + { + return AnnounceLoadResult::Unavailable; + } + + const std::string path = make_sd_path(kAnnouncesPath); + FILE* file = std::fopen(path.c_str(), "rb"); + if (!file) + { + return errno == ENOENT ? AnnounceLoadResult::Missing + : AnnounceLoadResult::IoError; + } + + AnnounceFileHeader header{}; + if (std::fread(&header, 1, sizeof(header), file) != sizeof(header) || + header.magic != kAnnounceMagic || + header.version != kAnnounceVersion || + header.record_size != sizeof(PersistedAnnounce) || + header.count > kMaxAnnounceRecords) + { + std::fclose(file); + return AnnounceLoadResult::IoError; + } + + out.resize(header.count); + const std::size_t payload_len = out.size() * sizeof(PersistedAnnounce); + if ((payload_len != 0 && + std::fread(out.data(), 1, payload_len, file) != payload_len) || + std::fclose(file) != 0) + { + out.clear(); + return AnnounceLoadResult::IoError; + } + if (fnv1a32(out.data(), payload_len) != header.checksum) + { + out.clear(); + return AnnounceLoadResult::IoError; + } + return AnnounceLoadResult::Loaded; +} + +bool save_persisted_announces(const std::vector& records) +{ + if (records.size() > kMaxAnnounceRecords || + !ensure_reticulum_directory()) + { + return false; + } + + const std::string path = make_sd_path(kAnnouncesPath); + const std::string temp_path = path + ".tmp"; + std::remove(temp_path.c_str()); + FILE* file = std::fopen(temp_path.c_str(), "wb"); + if (!file) + { + return false; + } + + AnnounceFileHeader header{}; + header.count = static_cast(records.size()); + const std::size_t payload_len = records.size() * sizeof(PersistedAnnounce); + header.checksum = fnv1a32(records.data(), payload_len); + bool ok = std::fwrite(&header, 1, sizeof(header), file) == sizeof(header); + if (ok && payload_len != 0) + { + ok = std::fwrite(records.data(), 1, payload_len, file) == payload_len; + } + ok = std::fclose(file) == 0 && ok; + if (!ok) + { + std::remove(temp_path.c_str()); + return false; + } + + std::remove(path.c_str()); + if (std::rename(temp_path.c_str(), path.c_str()) != 0) + { + std::remove(temp_path.c_str()); + return false; + } + return true; +} + +PersistedAnnounce make_persisted_announce(const AnnounceRecord& record) +{ + PersistedAnnounce out{}; + std::memcpy(out.destination_hash, + record.destination_hash, + sizeof(out.destination_hash)); + std::memcpy(out.identity_hash, + record.identity_hash, + sizeof(out.identity_hash)); + out.aspect = static_cast(record.aspect); + out.source = static_cast(record.source); + out.hops = record.hops; + out.flags = static_cast((record.path_response ? 1U : 0U) | + (record.local_destination ? 2U : 0U) | + (record.delivery ? 4U : 0U) | + (record.propagation ? 8U : 0U)); + out.first_seen_s = + record.first_seen_s != 0 ? record.first_seen_s : record.last_seen_s; + out.last_seen_s = + record.last_seen_s != 0 ? record.last_seen_s : out.first_seen_s; + copy_text(out.display_name, sizeof(out.display_name), record.display_name); + return out; +} + +AnnounceRecord make_announce_record(const PersistedAnnounce& record) +{ + AnnounceRecord out{}; + out.valid = true; + std::memcpy(out.destination_hash, + record.destination_hash, + sizeof(out.destination_hash)); + std::memcpy(out.identity_hash, + record.identity_hash, + sizeof(out.identity_hash)); + out.aspect = static_cast(record.aspect); + out.source = static_cast(record.source); + out.hops = record.hops; + out.path_response = (record.flags & 1U) != 0; + out.local_destination = (record.flags & 2U) != 0; + out.delivery = (record.flags & 4U) != 0; + out.propagation = (record.flags & 8U) != 0; + out.first_seen_s = record.first_seen_s; + out.last_seen_s = record.last_seen_s; + copy_text(out.display_name, sizeof(out.display_name), record.display_name); + return out; +} + +void upsert_persisted_announce(std::vector& records, + PersistedAnnounce persisted) +{ + const auto existing = std::find_if( + records.begin(), + records.end(), + [&persisted](const PersistedAnnounce& candidate) + { + return hash_equal(candidate.destination_hash, + persisted.destination_hash, + kReticulumHashSize); + }); + if (existing != records.end()) + { + if (existing->first_seen_s != 0 && + (persisted.first_seen_s == 0 || + existing->first_seen_s < persisted.first_seen_s)) + { + persisted.first_seen_s = existing->first_seen_s; + } + records.erase(existing); + } + if (records.size() >= kMaxAnnounceRecords) + { + records.erase(records.begin()); + } + records.push_back(persisted); +} + +void announce_worker_entry(void*) +{ + PersistedAnnounce first{}; + for (;;) + { + if (xQueueReceive(s_announce_queue, &first, portMAX_DELAY) != pdTRUE) + { + continue; + } + + vTaskDelay(pdMS_TO_TICKS(kAnnounceBatchDelayMs)); + std::vector batch; + batch.reserve(kAnnounceQueueDepth); + batch.push_back(first); + PersistedAnnounce pending{}; + while (batch.size() < kAnnounceQueueDepth && + xQueueReceive(s_announce_queue, &pending, 0) == pdTRUE) + { + batch.push_back(pending); + } + + std::vector records; + const AnnounceLoadResult loaded = load_persisted_announces(records); + if (loaded == AnnounceLoadResult::Unavailable || + loaded == AnnounceLoadResult::IoError) + { + std::printf("[Reticulum][Directory] announce load failed status=%u\n", + static_cast(loaded)); + continue; + } + for (const PersistedAnnounce& record : batch) + { + upsert_persisted_announce(records, record); + } + if (!save_persisted_announces(records)) + { + std::printf("[Reticulum][Directory] announce save failed count=%u\n", + static_cast(records.size())); + } + } +} + +bool ensure_announce_worker() +{ + if (!s_announce_queue) + { + s_announce_queue = + xQueueCreate(kAnnounceQueueDepth, sizeof(PersistedAnnounce)); + } + if (!s_announce_queue) + { + return false; + } + if (s_announce_worker) + { + return true; + } + + const BaseType_t created = xTaskCreate(announce_worker_entry, + "rt_announce_io", + kAnnounceWorkerStackBytes, + nullptr, + tskIDLE_PRIORITY + 1, + &s_announce_worker); + if (created != pdPASS) + { + s_announce_worker = nullptr; + return false; + } + return true; +} + +chat::MeshPeerSource mesh_source_from_entry_source(EntrySource source) +{ + switch (source) + { + case EntrySource::RuntimeRx: + return chat::MeshPeerSource::RuntimeRx; + case EntrySource::PathResponse: + return chat::MeshPeerSource::DiscoveryResponse; + case EntrySource::Manual: + return chat::MeshPeerSource::Manual; + case EntrySource::Import: + return chat::MeshPeerSource::Import; + case EntrySource::Unknown: + default: + return chat::MeshPeerSource::Unknown; + } +} + +EntrySource entry_source_from_mesh_source(chat::MeshPeerSource source) +{ + switch (source) + { + case chat::MeshPeerSource::RuntimeRx: + return EntrySource::RuntimeRx; + case chat::MeshPeerSource::DiscoveryResponse: + return EntrySource::PathResponse; + case chat::MeshPeerSource::Manual: + return EntrySource::Manual; + case chat::MeshPeerSource::Import: + return EntrySource::Import; + case chat::MeshPeerSource::Unknown: + default: + return EntrySource::Unknown; + } +} + +void init_lxmf_status(Status& out) +{ + out.supported = true; + out.sd_present = sd_ready(); + out.file_present = s_mesh_peer_directory != nullptr; +} + +chat::IMeshPeerDirectory* require_mesh_peer_directory(Status& out) +{ + init_lxmf_status(out); + if (!s_mesh_peer_directory) + { + set_status(out, "Mesh peer directory unavailable", kLxmfAddressesPath); + return nullptr; + } + return s_mesh_peer_directory; +} + +void set_mesh_peer_failure(Status& out, + chat::MeshPeerDirectoryStatusCode code, + const char* not_found_message, + const char* invalid_message) +{ + switch (code) + { + case chat::MeshPeerDirectoryStatusCode::NotFound: + set_status(out, not_found_message, kLxmfAddressesPath); + break; + case chat::MeshPeerDirectoryStatusCode::InvalidArgument: + set_status(out, invalid_message, kLxmfAddressesPath); + break; + case chat::MeshPeerDirectoryStatusCode::StorageUnavailable: + set_status(out, + "Mesh peer directory storage unavailable", + kLxmfAddressesPath); + break; + case chat::MeshPeerDirectoryStatusCode::IoError: + set_status(out, "Cannot access mesh peer directory", kLxmfAddressesPath); + break; + case chat::MeshPeerDirectoryStatusCode::CapacityExceeded: + set_status(out, "Mesh peer directory full", kLxmfAddressesPath); + break; + case chat::MeshPeerDirectoryStatusCode::Unsupported: + set_status(out, "Mesh peer directory unsupported", kLxmfAddressesPath); + break; + case chat::MeshPeerDirectoryStatusCode::Ok: + default: + set_status(out, "Mesh peer directory failed", kLxmfAddressesPath); + break; + } +} + +bool lxmf_to_mesh_peer(const LxmfAddressRecord& record, + chat::MeshPeerRecord& out) +{ + out = chat::MeshPeerRecord{}; + if (!record.valid || + zero_hash(record.destination_hash, kReticulumHashSize) || + zero_hash(record.identity_hash, kReticulumHashSize) || + zero_hash(record.enc_pub, kReticulumPublicKeySize) || + zero_hash(record.sig_pub, kReticulumPublicKeySize)) + { + return false; + } + + const chat::ReticulumPeerIdentity identity = + chat::makeReticulumPeerIdentity(record.destination_hash, + record.identity_hash); + out.valid = true; + out.identity = chat::makeMeshPeerReticulumIdentity(identity); + out.source = mesh_source_from_entry_source(record.source); + out.first_seen_s = + record.first_seen_s != 0 ? record.first_seen_s : record.last_seen_s; + out.last_seen_s = + record.last_seen_s != 0 ? record.last_seen_s : out.first_seen_s; + chat::copyMeshPeerText(out.display_name, + sizeof(out.display_name), + record.display_name); + out.flags.favorite = record.favorite; + out.flags.ignored = record.ignored; + out.flags.trusted = record.trusted; + out.reticulum.identity = identity; + out.reticulum.has_public_keys = true; + std::memcpy(out.reticulum.enc_pub, + record.enc_pub, + sizeof(out.reticulum.enc_pub)); + std::memcpy(out.reticulum.sig_pub, + record.sig_pub, + sizeof(out.reticulum.sig_pub)); + out.reticulum.delivery = true; + return chat::meshPeerRecordIsValid(out); +} + +bool mesh_peer_to_lxmf(const chat::MeshPeerRecord& record, + LxmfAddressRecord& out) +{ + out = LxmfAddressRecord{}; + if (!chat::meshPeerRecordIsValid(record) || + !chat::meshPeerIsReticulumProtocol(record.identity.protocol)) + { + return false; + } + + const chat::ReticulumPeerIdentity identity = + record.reticulum.identity.valid ? record.reticulum.identity + : record.identity.reticulum; + if (!identity.valid || + zero_hash(identity.destination_hash, kReticulumHashSize)) + { + return false; + } + + out.valid = true; + std::memcpy(out.destination_hash, + identity.destination_hash, + sizeof(out.destination_hash)); + std::memcpy(out.identity_hash, + identity.identity_hash, + sizeof(out.identity_hash)); + if (record.reticulum.has_public_keys) + { + std::memcpy(out.enc_pub, + record.reticulum.enc_pub, + sizeof(out.enc_pub)); + std::memcpy(out.sig_pub, + record.reticulum.sig_pub, + sizeof(out.sig_pub)); + } + copy_text(out.display_name, sizeof(out.display_name), record.display_name); + out.favorite = record.flags.favorite; + out.ignored = record.flags.ignored; + out.trusted = record.flags.trusted; + out.source = entry_source_from_mesh_source(record.source); + out.first_seen_s = record.first_seen_s; + out.last_seen_s = record.last_seen_s; + return true; +} + +Status record_lxmf_address_impl(const LxmfAddressRecord& record) { Status out{}; - out.supported = false; - set_status(out, message, detail); + chat::IMeshPeerDirectory* directory = require_mesh_peer_directory(out); + if (!directory) + { + return out; + } + + chat::MeshPeerRecord mesh_record{}; + if (!lxmf_to_mesh_peer(record, mesh_record)) + { + set_status(out, "Invalid LXMF address", kLxmfAddressesPath); + return out; + } + + chat::MeshPeerUserFlags merged_flags = mesh_record.flags; + chat::MeshPeerRecord existing{}; + if (directory->find(mesh_record.identity, existing).succeeded()) + { + merged_flags.favorite = existing.flags.favorite || record.favorite; + merged_flags.ignored = existing.flags.ignored || record.ignored; + merged_flags.trusted = existing.flags.trusted || record.trusted; + } + + const chat::MeshPeerDirectoryStatus status = directory->record(mesh_record); + if (!status.succeeded()) + { + set_mesh_peer_failure(out, + status.code, + "LXMF address not found", + "Invalid LXMF address"); + return out; + } + if (record.favorite || record.ignored || record.trusted) + { + const chat::MeshPeerDirectoryStatus flag_status = + directory->setUserFlags(mesh_record.identity, merged_flags); + if (!flag_status.succeeded()) + { + set_mesh_peer_failure(out, + flag_status.code, + "LXMF address not found", + "Invalid LXMF address"); + return out; + } + } + + out.saved = true; + out.loaded = true; + set_status(out, "LXMF address saved", kLxmfAddressesPath); + return out; +} + +Status load_lxmf_records(const char* query, + LxmfAddressRecord* out_records, + std::size_t max_records, + std::size_t* out_count) +{ + Status out{}; + if (out_count) + { + *out_count = 0; + } + chat::IMeshPeerDirectory* directory = require_mesh_peer_directory(out); + if (!directory) + { + return out; + } + if (!out_records || max_records == 0) + { + set_status(out, + "LXMF address storage unavailable", + kLxmfAddressesPath); + return out; + } + + std::vector records(max_records); + std::size_t loaded_count = 0; + const bool searching = query && query[0] != '\0'; + const chat::MeshPeerDirectoryStatus status = + searching + ? directory->search(chat::MeshProtocol::Reticulum, + query, + records.data(), + records.size(), + &loaded_count) + : directory->loadRecent(chat::MeshProtocol::Reticulum, + records.data(), + records.size(), + &loaded_count); + if (!status.succeeded()) + { + set_mesh_peer_failure(out, + status.code, + searching ? "No LXMF address matches" + : "No LXMF addresses", + "LXMF address storage unavailable"); + return out; + } + + std::size_t count = 0; + for (std::size_t index = 0; + index < loaded_count && count < max_records; + ++index) + { + LxmfAddressRecord converted{}; + if (mesh_peer_to_lxmf(records[index], converted)) + { + out_records[count++] = converted; + } + } + if (out_count) + { + *out_count = count; + } + out.loaded = true; + set_status(out, + count == 0 + ? (searching ? "No LXMF address matches" + : "No LXMF addresses") + : (searching ? "LXMF address matches loaded" + : "LXMF addresses loaded"), + kLxmfAddressesPath); return out; } @@ -47,76 +719,263 @@ const char* lxmf_addresses_path() return kLxmfAddressesPath; } -void bind_mesh_peer_directory(chat::IMeshPeerDirectory*) +void bind_mesh_peer_directory(chat::IMeshPeerDirectory* directory) { + s_mesh_peer_directory = directory; } -Status record_announce(const AnnounceRecord&) +Status record_announce(const AnnounceRecord& record) { - return unsupported("Reticulum announce storage unsupported", kAnnouncesPath); + Status out{}; + out.supported = true; + out.sd_present = sd_ready(); + out.file_present = is_regular_file(kAnnouncesPath); + if (!record.valid || + zero_hash(record.destination_hash, kReticulumHashSize) || + zero_hash(record.identity_hash, kReticulumHashSize)) + { + set_status(out, "Invalid Reticulum announce", kAnnouncesPath); + return out; + } + if (!out.sd_present) + { + set_status(out, "SD card required", kAnnouncesPath); + return out; + } + + if (!ensure_announce_worker()) + { + set_status(out, + "Reticulum announce queue unavailable", + kAnnouncesPath); + return out; + } + + const PersistedAnnounce persisted = make_persisted_announce(record); + if (xQueueSend(s_announce_queue, &persisted, 0) != pdTRUE) + { + set_status(out, "Reticulum announce queue full", kAnnouncesPath); + return out; + } + + out.saved = true; + set_status(out, "Reticulum announce queued", kAnnouncesPath); + return out; } -Status record_lxmf_address(const LxmfAddressRecord&) +Status record_lxmf_address(const LxmfAddressRecord& record) { - return unsupported("LXMF address storage unsupported", kLxmfAddressesPath); + return record_lxmf_address_impl(record); } -Status record_lxmf_address_now(const LxmfAddressRecord&) +Status record_lxmf_address_now(const LxmfAddressRecord& record) { - return unsupported("LXMF address storage unsupported", kLxmfAddressesPath); + return record_lxmf_address_impl(record); } -Status set_lxmf_address_favorite_now(const uint8_t*, bool) +Status set_lxmf_address_favorite_now( + const uint8_t destination_hash[kReticulumHashSize], + bool favorite) { - return unsupported("LXMF address storage unsupported", kLxmfAddressesPath); + Status out{}; + chat::IMeshPeerDirectory* directory = require_mesh_peer_directory(out); + if (!directory) + { + return out; + } + if (!destination_hash || zero_hash(destination_hash, kReticulumHashSize)) + { + set_status(out, "Invalid LXMF address", kLxmfAddressesPath); + return out; + } + + const chat::ReticulumPeerIdentity reticulum_identity = + chat::makeReticulumDestinationIdentity(destination_hash); + const chat::MeshPeerIdentity identity = + chat::makeMeshPeerReticulumIdentity(reticulum_identity); + chat::MeshPeerRecord record{}; + const chat::MeshPeerDirectoryStatus find_status = + directory->find(identity, record); + if (!find_status.succeeded()) + { + set_mesh_peer_failure(out, + find_status.code, + "LXMF address not found", + "Invalid LXMF address"); + return out; + } + + chat::MeshPeerUserFlags flags = record.flags; + flags.favorite = favorite; + const chat::MeshPeerDirectoryStatus flag_status = + directory->setUserFlags(record.identity, flags); + if (!flag_status.succeeded()) + { + set_mesh_peer_failure(out, + flag_status.code, + "LXMF address not found", + "Invalid LXMF address"); + return out; + } + out.saved = true; + out.loaded = true; + set_status(out, "LXMF address saved", kLxmfAddressesPath); + return out; } -Status load_announces(AnnounceRecord*, std::size_t, std::size_t* out_count) +Status load_announces(AnnounceRecord* out_records, + std::size_t max_records, + std::size_t* out_count) { + Status out{}; + out.supported = true; + out.sd_present = sd_ready(); + out.file_present = is_regular_file(kAnnouncesPath); if (out_count) { *out_count = 0; } - return unsupported("Reticulum announce storage unsupported", kAnnouncesPath); -} + if (!out_records || max_records == 0) + { + set_status(out, + "Reticulum announce storage unavailable", + kAnnouncesPath); + return out; + } + if (!out.sd_present) + { + set_status(out, "SD card required", kAnnouncesPath); + return out; + } -Status load_lxmf_addresses(LxmfAddressRecord*, std::size_t, std::size_t* out_count) -{ + std::vector records; + const AnnounceLoadResult loaded = load_persisted_announces(records); + if (loaded == AnnounceLoadResult::IoError || + loaded == AnnounceLoadResult::Unavailable) + { + set_status(out, "Cannot read Reticulum announces", kAnnouncesPath); + return out; + } + + const std::size_t count = std::min(max_records, records.size()); + for (std::size_t index = 0; index < count; ++index) + { + out_records[index] = + make_announce_record(records[records.size() - 1U - index]); + } if (out_count) { - *out_count = 0; + *out_count = count; } - return unsupported("LXMF address storage unsupported", kLxmfAddressesPath); + out.loaded = true; + set_status(out, + count == 0 ? "No Reticulum announces" + : "Reticulum announces loaded", + kAnnouncesPath); + return out; } -Status load_lxmf_addresses_matching(const char*, - LxmfAddressRecord*, - std::size_t, +Status load_lxmf_addresses(LxmfAddressRecord* out_records, + std::size_t max_records, + std::size_t* out_count) +{ + return load_lxmf_records(nullptr, out_records, max_records, out_count); +} + +Status load_lxmf_addresses_matching(const char* query, + LxmfAddressRecord* out_records, + std::size_t max_records, std::size_t* out_count) { - if (out_count) - { - *out_count = 0; - } - return unsupported("LXMF address storage unsupported", kLxmfAddressesPath); + return load_lxmf_records(query, out_records, max_records, out_count); } -Status find_lxmf_address_by_destination(const uint8_t*, LxmfAddressRecord* out_record) +Status find_lxmf_address_by_destination( + const uint8_t destination_hash[kReticulumHashSize], + LxmfAddressRecord* out_record) { + Status out{}; if (out_record) { *out_record = LxmfAddressRecord{}; } - return unsupported("LXMF address storage unsupported", kLxmfAddressesPath); + chat::IMeshPeerDirectory* directory = require_mesh_peer_directory(out); + if (!directory) + { + return out; + } + if (!destination_hash || !out_record || + zero_hash(destination_hash, kReticulumHashSize)) + { + set_status(out, "Invalid LXMF address", kLxmfAddressesPath); + return out; + } + + const chat::ReticulumPeerIdentity reticulum_identity = + chat::makeReticulumDestinationIdentity(destination_hash); + const chat::MeshPeerIdentity identity = + chat::makeMeshPeerReticulumIdentity(reticulum_identity); + chat::MeshPeerRecord record{}; + const chat::MeshPeerDirectoryStatus status = + directory->find(identity, record); + if (!status.succeeded()) + { + set_mesh_peer_failure(out, + status.code, + "LXMF address not found", + "Invalid LXMF address"); + return out; + } + if (!mesh_peer_to_lxmf(record, *out_record)) + { + set_status(out, "Invalid LXMF address", kLxmfAddressesPath); + return out; + } + out.loaded = true; + set_status(out, "LXMF address loaded", kLxmfAddressesPath); + return out; } -Status find_lxmf_address_by_node_id(uint32_t, LxmfAddressRecord* out_record) +Status find_lxmf_address_by_node_id(uint32_t node_id, + LxmfAddressRecord* out_record) { + Status out{}; if (out_record) { *out_record = LxmfAddressRecord{}; } - return unsupported("LXMF address storage unsupported", kLxmfAddressesPath); + chat::IMeshPeerDirectory* directory = require_mesh_peer_directory(out); + if (!directory) + { + return out; + } + if (node_id == 0 || !out_record) + { + set_status(out, "Invalid LXMF node", kLxmfAddressesPath); + return out; + } + + chat::MeshPeerRecord record{}; + const chat::MeshPeerDirectoryStatus status = + directory->findByNodeId(chat::MeshProtocol::Reticulum, + node_id, + record); + if (!status.succeeded()) + { + set_mesh_peer_failure(out, + status.code, + "LXMF address not found", + "Invalid LXMF node"); + return out; + } + if (!mesh_peer_to_lxmf(record, *out_record)) + { + set_status(out, "Invalid LXMF address", kLxmfAddressesPath); + return out; + } + out.loaded = true; + set_status(out, "LXMF address loaded", kLxmfAddressesPath); + return out; } } // namespace platform::ui::reticulum_directory @@ -162,11 +1021,18 @@ bool normalize_path(const char* path, char* out_path, std::size_t out_len) { return false; } - std::snprintf(out_path, out_len, "%s", (path && path[0]) ? path : "/page/index.mu"); + std::snprintf(out_path, + out_len, + "%s", + (path && path[0]) ? path : "/page/index.mu"); return true; } -Status load_cached_page(const char*, const char*, char*, std::size_t, std::size_t* out_body_len) +Status load_cached_page(const char*, + const char*, + char*, + std::size_t, + std::size_t* out_body_len) { if (out_body_len) { @@ -180,7 +1046,11 @@ Status request_cached_page_load(const char*, const char*, bool) return unsupported("Nomad page cache unsupported", kPagesPath); } -Status poll_cached_page_load(const char*, const char*, char*, std::size_t, std::size_t* out_body_len) +Status poll_cached_page_load(const char*, + const char*, + char*, + std::size_t, + std::size_t* out_body_len) { if (out_body_len) { @@ -189,7 +1059,10 @@ Status poll_cached_page_load(const char*, const char*, char*, std::size_t, std:: return unsupported("Nomad page cache unsupported", kPagesPath); } -Status store_cached_page_now(const char*, const char*, const char*, std::size_t) +Status store_cached_page_now(const char*, + const char*, + const char*, + std::size_t) { return unsupported("Nomad page cache unsupported", kPagesPath); } diff --git a/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp b/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp new file mode 100644 index 00000000..19a20b04 --- /dev/null +++ b/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp @@ -0,0 +1,361 @@ +#include "platform/esp/idf_common/reticulum_call_runtime_support.h" + +#include "platform/esp/common/reticulum_call_audio_engine.h" +#include "platform/ui/audio/call_notification_tone.h" +#include "platform/ui/audio/pager_notification_tone.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/reticulum_call_runtime.h" +#include "platform/ui/wifi_access_runtime.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include + +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +#include "boards/t_display_p4/t_display_p4_board.h" +#include "bsp/trail_mate_t_display_p4_runtime.h" +#endif + +namespace platform::esp::idf_common::reticulum_call_support +{ +namespace +{ + +constexpr uint32_t kIncomingToneGapMs = 600; +constexpr uint32_t kIncomingToneStopTimeoutMs = 500; +constexpr uint32_t kIncomingToneTaskStackBytes = 5 * 1024; +constexpr UBaseType_t kIncomingToneTaskPriority = tskIDLE_PRIORITY + 1; +constexpr std::size_t kToneChunkFrames = 128; + +bool s_registered = false; +bool s_resources_suspended = false; +TaskHandle_t s_incoming_tone_task = nullptr; +volatile bool s_incoming_tone_stop_requested = false; + +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +constexpr auto kCallOwner = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_RETICULUM_CALL; +constexpr auto kNotificationOwner = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NOTIFICATION; + +bool media_supported() +{ + return ::boards::t_display_p4::TDisplayP4Board::hasAudio(); +} + +bool open_call_audio(uint32_t sample_rate_hz) +{ + return trail_mate_t_display_p4_audio_begin(kCallOwner, sample_rate_hz); +} + +void close_call_audio() +{ + trail_mate_t_display_p4_audio_end(kCallOwner); +} + +bool read_call_audio(int16_t* pcm, std::size_t sample_count) +{ + return trail_mate_t_display_p4_audio_read_mono(kCallOwner, pcm, sample_count); +} + +bool write_call_audio(const int16_t* pcm, std::size_t sample_count) +{ + return trail_mate_t_display_p4_audio_write_mono(kCallOwner, pcm, sample_count); +} + +uint8_t speaker_volume() +{ + return ::boards::t_display_p4::TDisplayP4Board::instance().getMessageToneVolume(); +} + +void set_backend_volume(uint8_t volume_percent) +{ + const uint8_t volume = volume_percent > 100U ? 100U : volume_percent; + ::boards::t_display_p4::TDisplayP4Board::instance().setMessageToneVolume(volume); + trail_mate_t_display_p4_audio_set_volume_percent(volume); +} +#else +bool media_supported() +{ + return false; +} + +bool open_call_audio(uint32_t) +{ + return false; +} + +void close_call_audio() +{ +} + +bool read_call_audio(int16_t*, std::size_t) +{ + return false; +} + +bool write_call_audio(const int16_t*, std::size_t) +{ + return false; +} + +uint8_t speaker_volume() +{ + return 0; +} + +void set_backend_volume(uint8_t) +{ +} +#endif + +void suspend_resources() +{ + if (s_resources_suspended) + { + return; + } + ::platform::ui::gps::suspend_runtime(); + s_resources_suspended = true; +} + +void resume_resources() +{ + if (!s_resources_suspended) + { + return; + } + ::platform::ui::gps::resume_runtime(); + s_resources_suspended = false; +} + +void incoming_tone_task(void*) +{ + while (!s_incoming_tone_stop_requested && + ::platform::ui::reticulum_call::realtime_phase() == + ::platform::ui::reticulum_call::RealtimePhase::IncomingRinging) + { + (void)play_incoming_notification(&s_incoming_tone_stop_requested); + const TickType_t gap_started = xTaskGetTickCount(); + while (!s_incoming_tone_stop_requested && + ::platform::ui::reticulum_call::realtime_phase() == + ::platform::ui::reticulum_call::RealtimePhase::IncomingRinging && + pdTICKS_TO_MS(xTaskGetTickCount() - gap_started) < kIncomingToneGapMs) + { + vTaskDelay(pdMS_TO_TICKS(20)); + } + } + s_incoming_tone_task = nullptr; + vTaskDelete(nullptr); +} + +bool start_incoming_tone() +{ + if (s_incoming_tone_task) + { + return true; + } + s_incoming_tone_stop_requested = false; + const BaseType_t result = xTaskCreate(incoming_tone_task, + "rt_call_ring", + kIncomingToneTaskStackBytes, + nullptr, + kIncomingToneTaskPriority, + &s_incoming_tone_task); + if (result != pdPASS) + { + s_incoming_tone_task = nullptr; + return false; + } + return true; +} + +bool stop_incoming_tone() +{ + s_incoming_tone_stop_requested = true; + const TaskHandle_t task = s_incoming_tone_task; + if (!task) + { + return true; + } + if (xTaskGetCurrentTaskHandle() == task) + { + return false; + } + + const TickType_t started = xTaskGetTickCount(); + while (s_incoming_tone_task && + pdTICKS_TO_MS(xTaskGetTickCount() - started) < kIncomingToneStopTimeoutMs) + { + vTaskDelay(pdMS_TO_TICKS(5)); + } + return s_incoming_tone_task == nullptr; +} + +bool begin_ringing(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) +{ + suspend_resources(); + const bool entered = ::platform::ui::wifi_access::enter_call_ringing(link_id); + if (entered && !start_incoming_tone()) + { + std::printf("[Reticulum][CallTone] start failed\n"); + } + return entered; +} + +bool begin_exclusive(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) +{ + const bool was_ringing = + ::platform::ui::reticulum_call::realtime_phase() == + ::platform::ui::reticulum_call::RealtimePhase::IncomingRinging; + if (!stop_incoming_tone()) + { + std::printf("[Reticulum][CallTone] stop timeout\n"); + return false; + } + if (!::platform::ui::wifi_access::enter_call_exclusive(link_id)) + { + if (was_ringing) + { + (void)start_incoming_tone(); + } + return false; + } + suspend_resources(); + return true; +} + +void begin_closing(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize], + bool keep_exclusive) +{ + (void)stop_incoming_tone(); + suspend_resources(); + ::platform::ui::wifi_access::enter_call_closing(link_id, keep_exclusive); +} + +void end_realtime(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) +{ + (void)stop_incoming_tone(); + ::platform::ui::wifi_access::exit_call(link_id); + resume_resources(); +} + +} // namespace + +void ensure_registered() +{ + if (s_registered) + { + return; + } + + ::platform::esp::common::reticulum_call_audio::Backend audio_backend{}; + audio_backend.is_supported = media_supported; + audio_backend.open = open_call_audio; + audio_backend.close = close_call_audio; + audio_backend.read_mono = read_call_audio; + audio_backend.write_mono = write_call_audio; + audio_backend.speaker_volume = speaker_volume; + audio_backend.set_speaker_volume = set_backend_volume; + if (!::platform::esp::common::reticulum_call_audio::install_backend(audio_backend)) + { + std::printf("[Reticulum][CallAudio] backend registration failed\n"); + return; + } + + ::platform::ui::reticulum_call::RealtimeHooks realtime_hooks{}; + realtime_hooks.begin_ringing = begin_ringing; + realtime_hooks.begin_exclusive = begin_exclusive; + realtime_hooks.begin_closing = begin_closing; + realtime_hooks.end = end_realtime; + ::platform::ui::reticulum_call::set_realtime_hooks(realtime_hooks); + s_registered = true; +} + +void set_speaker_volume(uint8_t volume_percent) +{ + set_backend_volume(volume_percent); +} + +bool play_message_notification() +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + namespace tone = ::platform::ui::audio::pager_notification; + if (speaker_volume() == 0 || + !trail_mate_t_display_p4_audio_begin(kNotificationOwner, + tone::kPlaybackSampleRateHz)) + { + return false; + } + + std::array pcm{}; + tone::AdpcmPlaybackState state{}; + bool success = true; + while (tone::hasMore(state)) + { + std::size_t frames = 0; + while (frames < pcm.size() && tone::nextPlaybackSample(state, pcm[frames])) + { + ++frames; + } + if (frames == 0) + { + break; + } + if (!trail_mate_t_display_p4_audio_write_mono(kNotificationOwner, + pcm.data(), + frames)) + { + success = false; + break; + } + } + trail_mate_t_display_p4_audio_end(kNotificationOwner); + return success; +#else + return false; +#endif +} + +bool play_incoming_notification(const volatile bool* stop_requested) +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + namespace tone = ::platform::ui::audio::call_notification; + if ((stop_requested && *stop_requested) || speaker_volume() == 0 || + !trail_mate_t_display_p4_audio_begin(kNotificationOwner, + tone::kPlaybackSampleRateHz)) + { + return false; + } + + std::array pcm{}; + tone::AdpcmPlaybackState state{}; + bool success = true; + while (tone::hasMore(state) && (!stop_requested || !*stop_requested)) + { + std::size_t frames = 0; + while (frames < pcm.size() && tone::nextPlaybackSample(state, pcm[frames])) + { + ++frames; + } + if (frames == 0) + { + break; + } + if (!trail_mate_t_display_p4_audio_write_mono(kNotificationOwner, + pcm.data(), + frames)) + { + success = false; + break; + } + } + trail_mate_t_display_p4_audio_end(kNotificationOwner); + return success; +#else + (void)stop_requested; + return false; +#endif +} + +} // namespace platform::esp::idf_common::reticulum_call_support diff --git a/platform/esp/idf_common/src/reticulum_crypto_compat.cpp b/platform/esp/idf_common/src/reticulum_crypto_compat.cpp new file mode 100644 index 00000000..1f34b62a --- /dev/null +++ b/platform/esp/idf_common/src/reticulum_crypto_compat.cpp @@ -0,0 +1,150 @@ +#include "platform/esp/common/reticulum_crypto_compat.h" + +#if defined(ESP_PLATFORM) && !defined(ARDUINO) + +#include "esp_random.h" +#include "mbedtls/ecp.h" + +#include + +namespace +{ + +constexpr std::size_t kX25519KeySize = 32; + +int fill_random(void*, unsigned char* out, std::size_t len) +{ + if (!out && len != 0) + { + return -1; + } + esp_fill_random(out, len); + return 0; +} + +bool all_zero(const std::uint8_t* data, std::size_t len) +{ + if (!data) + { + return true; + } + + std::uint8_t combined = 0; + for (std::size_t index = 0; index < len; ++index) + { + combined |= data[index]; + } + return combined == 0; +} + +bool multiply_x25519(const std::uint8_t scalar[kX25519KeySize], + const std::uint8_t point[kX25519KeySize], + std::uint8_t out[kX25519KeySize]) +{ + if (!scalar || !point || !out) + { + return false; + } + + mbedtls_ecp_group group; + mbedtls_ecp_point input; + mbedtls_ecp_point result; + mbedtls_mpi private_scalar; + mbedtls_ecp_group_init(&group); + mbedtls_ecp_point_init(&input); + mbedtls_ecp_point_init(&result); + mbedtls_mpi_init(&private_scalar); + + int status = mbedtls_ecp_group_load(&group, MBEDTLS_ECP_DP_CURVE25519); + if (status == 0) + { + status = mbedtls_mpi_read_binary_le(&private_scalar, scalar, kX25519KeySize); + } + if (status == 0) + { + status = mbedtls_mpi_read_binary_le(&input.MBEDTLS_PRIVATE(X), + point, + kX25519KeySize); + } + if (status == 0) + { + status = mbedtls_mpi_lset(&input.MBEDTLS_PRIVATE(Z), 1); + } + if (status == 0) + { + status = mbedtls_ecp_mul(&group, + &result, + &private_scalar, + &input, + fill_random, + nullptr); + } + if (status == 0) + { + status = mbedtls_mpi_write_binary_le(&result.MBEDTLS_PRIVATE(X), + out, + kX25519KeySize); + } + + mbedtls_mpi_free(&private_scalar); + mbedtls_ecp_point_free(&result); + mbedtls_ecp_point_free(&input); + mbedtls_ecp_group_free(&group); + return status == 0; +} + +} // namespace + +ReticulumRngCompat RNG{}; + +void ReticulumRngCompat::begin(const char*) const +{ + // ESP-IDF entropy is provided directly by esp_fill_random(). +} + +void Curve25519::dh1(std::uint8_t public_key[32], std::uint8_t private_key[32]) +{ + if (!public_key || !private_key) + { + return; + } + + const std::uint8_t base_point[kX25519KeySize] = {9}; + for (std::size_t attempt = 0; attempt < 16; ++attempt) + { + esp_fill_random(private_key, kX25519KeySize); + private_key[0] &= 0xF8U; + private_key[31] = static_cast((private_key[31] & 0x7FU) | 0x40U); + if (multiply_x25519(private_key, base_point, public_key) && + !all_zero(public_key, kX25519KeySize)) + { + return; + } + } + + std::memset(public_key, 0, kX25519KeySize); + std::memset(private_key, 0, kX25519KeySize); +} + +bool Curve25519::dh2(std::uint8_t peer_key_and_secret[32], std::uint8_t private_key[32]) +{ + if (!peer_key_and_secret || !private_key) + { + return false; + } + + std::uint8_t peer_key[kX25519KeySize] = {}; + std::memcpy(peer_key, peer_key_and_secret, sizeof(peer_key)); + const bool ok = !all_zero(peer_key, sizeof(peer_key)) && + multiply_x25519(private_key, peer_key, peer_key_and_secret) && + !all_zero(peer_key_and_secret, kX25519KeySize); + std::memset(peer_key, 0, sizeof(peer_key)); + std::memset(private_key, 0, kX25519KeySize); + if (!ok) + { + std::memset(peer_key_and_secret, 0, kX25519KeySize); + } + return ok; +} + +#endif diff --git a/platform/esp/idf_common/src/reticulum_runtime_compat.cpp b/platform/esp/idf_common/src/reticulum_runtime_compat.cpp new file mode 100644 index 00000000..8535a6ca --- /dev/null +++ b/platform/esp/idf_common/src/reticulum_runtime_compat.cpp @@ -0,0 +1,50 @@ +#include "platform/esp/common/reticulum_runtime_compat.h" + +#if defined(ESP_PLATFORM) && !defined(ARDUINO) + +#include "esp_timer.h" + +#include +#include + +namespace platform::esp::common::reticulum_runtime +{ +namespace +{ + +Console s_console{}; + +} // namespace + +int Console::printf(const char* format, ...) +{ + if (!format) + { + return 0; + } + + va_list args; + va_start(args, format); + const int written = std::vprintf(format, args); + va_end(args); + return written; +} + +void Console::println(const char* text) +{ + std::puts(text ? text : ""); +} + +Console& console() +{ + return s_console; +} + +std::uint32_t monotonic_millis() +{ + return static_cast(esp_timer_get_time() / 1000ULL); +} + +} // namespace platform::esp::common::reticulum_runtime + +#endif diff --git a/platform/esp/idf_common/src/screen_sleep.cpp b/platform/esp/idf_common/src/screen_sleep.cpp index a0243b90..89265329 100644 --- a/platform/esp/idf_common/src/screen_sleep.cpp +++ b/platform/esp/idf_common/src/screen_sleep.cpp @@ -350,6 +350,18 @@ void enterFromScreenSaver() platform::esp::idf_common::ui_dispatcher::Event::ShowMainMenu); } +void wakeScreenForModal() +{ + ensure_mutex(); + if (s_mutex && xSemaphoreTake(s_mutex, portMAX_DELAY) == pdTRUE) + { + enter_ui_locked(); + xSemaphoreGive(s_mutex); + } + platform::esp::idf_common::ui_dispatcher::post( + platform::esp::idf_common::ui_dispatcher::Event::HideScreenSaver); +} + void updateUserActivity() { bool woke_from_sleep = false; @@ -439,6 +451,7 @@ bool is_sleep_disabled() { return isScreenSleepDisabled(); } bool is_saver_active() { return isScreenSaverActive(); } void wake_saver() { wakeScreenSaver(); } void enter_from_saver() { enterFromScreenSaver(); } +void wake_for_modal() { wakeScreenForModal(); } void update_user_activity() { updateUserActivity(); } void disable_sleep() { disableScreenSleep(); } void enable_sleep() { enableScreenSleep(); } diff --git a/platform/esp/idf_common/src/startup_support.cpp b/platform/esp/idf_common/src/startup_support.cpp index c12a9303..fdbd9bd4 100644 --- a/platform/esp/idf_common/src/startup_support.cpp +++ b/platform/esp/idf_common/src/startup_support.cpp @@ -1,15 +1,49 @@ #include "platform/esp/idf_common/startup_support.h" +#include #include +#include #include "esp_app_desc.h" #include "esp_idf_version.h" #include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "platform/esp/common/build_info.h" +#include "sys/clock.h" + +namespace +{ + +uint32_t platform_millis_now() +{ + return static_cast(esp_timer_get_time() / 1000ULL); +} + +uint32_t platform_epoch_seconds_now() +{ + const std::time_t now = std::time(nullptr); + return now < 0 ? 0U : static_cast(now); +} + +void platform_sleep_ms(uint32_t ms) +{ + vTaskDelay(pdMS_TO_TICKS(ms)); +} + +} // namespace namespace platform::esp::idf_common::startup_support { +void initializeClockProviders() +{ + sys::set_millis_provider(platform_millis_now); + sys::set_epoch_seconds_provider(platform_epoch_seconds_now); + sys::set_sleep_provider(platform_sleep_ms); +} + void logStartupBanner(const char* tag) { const esp_app_desc_t* desc = esp_app_get_description(); diff --git a/platform/esp/idf_common/src/usb_console_runtime.cpp b/platform/esp/idf_common/src/usb_console_runtime.cpp new file mode 100644 index 00000000..23b08f2e --- /dev/null +++ b/platform/esp/idf_common/src/usb_console_runtime.cpp @@ -0,0 +1,83 @@ +#include "platform/esp/idf_common/usb_console_runtime.h" + +#include "sdkconfig.h" + +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) && defined(CONFIG_TINYUSB_CDC_ENABLED) +#include + +#include "esp_err.h" +#include "tinyusb.h" +#include "tusb_cdc_acm.h" +#include "tusb_console.h" +#endif + +namespace platform::esp::idf_common::usb_console +{ +namespace +{ + +bool s_started = false; + +} // namespace + +bool ensure_started() +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) && defined(CONFIG_TINYUSB_CDC_ENABLED) + if (s_started) + { + return true; + } + + const tinyusb_config_t usb_config = { + .device_descriptor = nullptr, + .string_descriptor = nullptr, + .external_phy = false, +#if TUD_OPT_HIGH_SPEED + .fs_configuration_descriptor = nullptr, + .hs_configuration_descriptor = nullptr, + .qualifier_descriptor = nullptr, +#else + .configuration_descriptor = nullptr, +#endif + }; + if (tinyusb_driver_install(&usb_config) != ESP_OK) + { + return false; + } + + const tinyusb_config_cdcacm_t cdc_config = { + .usb_dev = TINYUSB_USBDEV_0, + .cdc_port = TINYUSB_CDC_ACM_0, + .rx_unread_buf_sz = CONFIG_TINYUSB_CDC_RX_BUFSIZE, + .callback_rx = nullptr, + .callback_rx_wanted_char = nullptr, + .callback_line_state_changed = nullptr, + .callback_line_coding_changed = nullptr, + }; + if (tusb_cdc_acm_init(&cdc_config) != ESP_OK) + { + tinyusb_driver_uninstall(); + return false; + } + if (esp_tusb_init_console(TINYUSB_CDC_ACM_0) != ESP_OK) + { + (void)tusb_cdc_acm_deinit(TINYUSB_CDC_ACM_0); + tinyusb_driver_uninstall(); + return false; + } + + setvbuf(stdout, nullptr, _IONBF, 0); + setvbuf(stderr, nullptr, _IONBF, 0); + s_started = true; + return true; +#else + return true; +#endif +} + +bool is_started() +{ + return s_started; +} + +} // namespace platform::esp::idf_common::usb_console diff --git a/platform/esp/idf_components/t_display_p4/CMakeLists.txt b/platform/esp/idf_components/t_display_p4/CMakeLists.txt index ec4eedbf..79fe0ae1 100644 --- a/platform/esp/idf_components/t_display_p4/CMakeLists.txt +++ b/platform/esp/idf_components/t_display_p4/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( SRCS "trail_mate_t_display_p4_runtime.cpp" + "t_display_p4_audio_runtime.cpp" "${CMAKE_SOURCE_DIR}/boards/t_display_p4/src/runtime_support.cpp" "hi8561_driver.c" "rm69a10_driver.c" @@ -18,6 +19,8 @@ idf_component_register( esp_driver_gpio esp_driver_i2c esp_driver_ledc + esp_driver_i2s + esp_codec_dev PRIV_REQUIRES main ) diff --git a/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h b/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h index d171f98e..420cd553 100644 --- a/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h +++ b/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "esp_err.h" @@ -16,6 +17,25 @@ extern "C" void trail_mate_t_display_p4_display_unlock(void); esp_err_t trail_mate_t_display_p4_display_set_brightness_percent(int brightness_percent); + typedef enum + { + TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE = 0, + TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NOTIFICATION = 1, + TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_RETICULUM_CALL = 2, + } trail_mate_t_display_p4_audio_owner_t; + + bool trail_mate_t_display_p4_audio_is_ready(void); + bool trail_mate_t_display_p4_audio_begin(trail_mate_t_display_p4_audio_owner_t owner, + uint32_t sample_rate_hz); + void trail_mate_t_display_p4_audio_end(trail_mate_t_display_p4_audio_owner_t owner); + bool trail_mate_t_display_p4_audio_read_mono(trail_mate_t_display_p4_audio_owner_t owner, + int16_t* pcm, + size_t sample_count); + bool trail_mate_t_display_p4_audio_write_mono(trail_mate_t_display_p4_audio_owner_t owner, + const int16_t* pcm, + size_t sample_count); + void trail_mate_t_display_p4_audio_set_volume_percent(uint8_t volume_percent); + #ifdef __cplusplus } #endif diff --git a/platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp b/platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp new file mode 100644 index 00000000..f98f39d9 --- /dev/null +++ b/platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp @@ -0,0 +1,358 @@ +#include "bsp/trail_mate_t_display_p4_runtime.h" + +#include "boards/t_display_p4/t_display_p4_board.h" + +#include "driver/i2s_std.h" +#include "esp_codec_dev.h" +#include "esp_codec_dev_defaults.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include +#include + +namespace +{ + +constexpr char kTag[] = "p4_audio"; +constexpr i2s_port_t kI2sPort = I2S_NUM_0; +constexpr uint32_t kInitialSampleRateHz = 16000; +constexpr int kBitsPerSample = 16; +constexpr int kPhysicalChannels = 2; +constexpr int kMclkMultiple = 384; +constexpr float kMicrophoneGainDb = 36.0f; +constexpr std::size_t kScratchFrames = 320; + +struct AudioRuntime +{ + StaticSemaphore_t mutex_storage{}; + SemaphoreHandle_t mutex = nullptr; + i2s_chan_handle_t tx = nullptr; + i2s_chan_handle_t rx = nullptr; + const audio_codec_data_if_t* data_if = nullptr; + const audio_codec_ctrl_if_t* ctrl_if = nullptr; + const audio_codec_if_t* codec_if = nullptr; + esp_codec_dev_handle_t codec = nullptr; + trail_mate_t_display_p4_audio_owner_t owner = + TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE; + uint32_t sample_rate_hz = 0; + uint8_t volume_percent = 45; + bool init_attempted = false; + bool ready = false; + std::array stereo_scratch{}; +}; + +AudioRuntime s_audio{}; + +SemaphoreHandle_t audio_mutex() +{ + if (!s_audio.mutex) + { + s_audio.mutex = xSemaphoreCreateMutexStatic(&s_audio.mutex_storage); + } + return s_audio.mutex; +} + +class AudioLock +{ + public: + AudioLock() + { + SemaphoreHandle_t mutex = audio_mutex(); + locked_ = mutex && xSemaphoreTake(mutex, pdMS_TO_TICKS(1000)) == pdTRUE; + } + + ~AudioLock() + { + if (locked_) + { + xSemaphoreGive(s_audio.mutex); + } + } + + explicit operator bool() const { return locked_; } + + private: + bool locked_ = false; +}; + +bool initialize_audio_locked() +{ + if (s_audio.ready) + { + return true; + } + if (s_audio.init_attempted) + { + return false; + } + s_audio.init_attempted = true; + + auto& board = ::boards::t_display_p4::TDisplayP4Board::instance(); + if (!board.ensureExternal3v3Power() || !board.externalI2cHandle()) + { + ESP_LOGE(kTag, "external 3V3 or I2C bus unavailable"); + return false; + } + + i2s_chan_config_t channel_config = I2S_CHANNEL_DEFAULT_CONFIG(kI2sPort, I2S_ROLE_MASTER); + channel_config.auto_clear = true; + if (i2s_new_channel(&channel_config, &s_audio.tx, &s_audio.rx) != ESP_OK) + { + ESP_LOGE(kTag, "I2S channel creation failed"); + return false; + } + + const auto& pins = board.profile().audio_i2s; + i2s_std_config_t i2s_config{}; + i2s_config.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(kInitialSampleRateHz); + i2s_config.clk_cfg.mclk_multiple = I2S_MCLK_MULTIPLE_384; + i2s_config.slot_cfg = + I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, + I2S_SLOT_MODE_STEREO); + i2s_config.gpio_cfg.mclk = static_cast(pins.mclk); + i2s_config.gpio_cfg.bclk = static_cast(pins.bclk); + i2s_config.gpio_cfg.ws = static_cast(pins.ws); + i2s_config.gpio_cfg.dout = static_cast(pins.dout); + i2s_config.gpio_cfg.din = static_cast(pins.din); + i2s_config.gpio_cfg.invert_flags = {}; + + if (i2s_channel_init_std_mode(s_audio.tx, &i2s_config) != ESP_OK || + i2s_channel_init_std_mode(s_audio.rx, &i2s_config) != ESP_OK) + { + ESP_LOGE(kTag, "I2S standard mode initialization failed"); + return false; + } + + audio_codec_i2s_cfg_t data_config{}; + data_config.port = static_cast(kI2sPort); + data_config.rx_handle = s_audio.rx; + data_config.tx_handle = s_audio.tx; + s_audio.data_if = audio_codec_new_i2s_data(&data_config); + if (!s_audio.data_if) + { + ESP_LOGE(kTag, "codec I2S data interface unavailable"); + return false; + } + + audio_codec_i2c_cfg_t control_config{}; + control_config.port = static_cast(board.externalI2c().port); + control_config.addr = ES8311_CODEC_DEFAULT_ADDR; + control_config.bus_handle = board.externalI2cHandle(); + s_audio.ctrl_if = audio_codec_new_i2c_ctrl(&control_config); + if (!s_audio.ctrl_if) + { + ESP_LOGE(kTag, "ES8311 I2C control interface unavailable"); + return false; + } + + es8311_codec_cfg_t codec_config{}; + codec_config.ctrl_if = s_audio.ctrl_if; + codec_config.codec_mode = ESP_CODEC_DEV_WORK_MODE_BOTH; + codec_config.pa_pin = -1; + codec_config.master_mode = false; + codec_config.use_mclk = true; + codec_config.digital_mic = false; + codec_config.no_dac_ref = true; + codec_config.mclk_div = kMclkMultiple; + s_audio.codec_if = es8311_codec_new(&codec_config); + if (!s_audio.codec_if) + { + ESP_LOGE(kTag, "ES8311 codec interface unavailable"); + return false; + } + + esp_codec_dev_cfg_t device_config{}; + device_config.dev_type = ESP_CODEC_DEV_TYPE_IN_OUT; + device_config.codec_if = s_audio.codec_if; + device_config.data_if = s_audio.data_if; + s_audio.codec = esp_codec_dev_new(&device_config); + if (!s_audio.codec) + { + ESP_LOGE(kTag, "ES8311 codec device unavailable"); + return false; + } + + s_audio.ready = true; + ESP_LOGI(kTag, + "ES8311 ready i2c=%d pins(mclk=%d bclk=%d ws=%d dout=%d din=%d)", + board.externalI2c().port, + pins.mclk, + pins.bclk, + pins.ws, + pins.dout, + pins.din); + return true; +} + +bool owner_active_locked(trail_mate_t_display_p4_audio_owner_t owner) +{ + return owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE && + s_audio.owner == owner && s_audio.codec; +} + +} // namespace + +extern "C" bool trail_mate_t_display_p4_audio_is_ready(void) +{ + AudioLock lock; + return lock && initialize_audio_locked(); +} + +extern "C" bool trail_mate_t_display_p4_audio_begin( + trail_mate_t_display_p4_audio_owner_t owner, + uint32_t sample_rate_hz) +{ + if (owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE || sample_rate_hz == 0) + { + return false; + } + + AudioLock lock; + if (!lock || !initialize_audio_locked()) + { + return false; + } + if (s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE) + { + return s_audio.owner == owner && s_audio.sample_rate_hz == sample_rate_hz; + } + + esp_codec_dev_sample_info_t sample_info{}; + sample_info.bits_per_sample = kBitsPerSample; + sample_info.channel = kPhysicalChannels; + sample_info.sample_rate = sample_rate_hz; + sample_info.mclk_multiple = kMclkMultiple; + const int open_result = esp_codec_dev_open(s_audio.codec, &sample_info); + if (open_result != ESP_CODEC_DEV_OK) + { + ESP_LOGE(kTag, + "open failed owner=%u rate=%lu rc=%d", + static_cast(owner), + static_cast(sample_rate_hz), + open_result); + (void)esp_codec_dev_close(s_audio.codec); + return false; + } + + (void)esp_codec_dev_set_in_gain(s_audio.codec, kMicrophoneGainDb); + (void)esp_codec_dev_set_out_vol(s_audio.codec, s_audio.volume_percent); + (void)esp_codec_dev_set_out_mute(s_audio.codec, s_audio.volume_percent == 0); + s_audio.owner = owner; + s_audio.sample_rate_hz = sample_rate_hz; + ESP_LOGI(kTag, + "session begin owner=%u rate=%lu volume=%u", + static_cast(owner), + static_cast(sample_rate_hz), + static_cast(s_audio.volume_percent)); + return true; +} + +extern "C" void trail_mate_t_display_p4_audio_end( + trail_mate_t_display_p4_audio_owner_t owner) +{ + AudioLock lock; + if (!lock || !owner_active_locked(owner)) + { + return; + } + + (void)esp_codec_dev_set_out_mute(s_audio.codec, true); + (void)esp_codec_dev_close(s_audio.codec); + s_audio.owner = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE; + s_audio.sample_rate_hz = 0; + ESP_LOGI(kTag, "session end owner=%u", static_cast(owner)); +} + +extern "C" bool trail_mate_t_display_p4_audio_read_mono( + trail_mate_t_display_p4_audio_owner_t owner, + int16_t* pcm, + size_t sample_count) +{ + if (!pcm || sample_count == 0) + { + return false; + } + + AudioLock lock; + if (!lock || !owner_active_locked(owner)) + { + return false; + } + + std::size_t offset = 0; + while (offset < sample_count) + { + const std::size_t frames = std::min(kScratchFrames, sample_count - offset); + const int result = esp_codec_dev_read(s_audio.codec, + s_audio.stereo_scratch.data(), + static_cast(frames * kPhysicalChannels * + sizeof(int16_t))); + if (result != ESP_CODEC_DEV_OK) + { + return false; + } + for (std::size_t index = 0; index < frames; ++index) + { + pcm[offset + index] = s_audio.stereo_scratch[index * kPhysicalChannels]; + } + offset += frames; + } + return true; +} + +extern "C" bool trail_mate_t_display_p4_audio_write_mono( + trail_mate_t_display_p4_audio_owner_t owner, + const int16_t* pcm, + size_t sample_count) +{ + if (!pcm || sample_count == 0) + { + return false; + } + + AudioLock lock; + if (!lock || !owner_active_locked(owner)) + { + return false; + } + + std::size_t offset = 0; + while (offset < sample_count) + { + const std::size_t frames = std::min(kScratchFrames, sample_count - offset); + for (std::size_t index = 0; index < frames; ++index) + { + const int16_t sample = pcm[offset + index]; + s_audio.stereo_scratch[index * kPhysicalChannels] = sample; + s_audio.stereo_scratch[(index * kPhysicalChannels) + 1] = sample; + } + const int result = esp_codec_dev_write(s_audio.codec, + s_audio.stereo_scratch.data(), + static_cast(frames * kPhysicalChannels * + sizeof(int16_t))); + if (result != ESP_CODEC_DEV_OK) + { + return false; + } + offset += frames; + } + return true; +} + +extern "C" void trail_mate_t_display_p4_audio_set_volume_percent(uint8_t volume_percent) +{ + AudioLock lock; + if (!lock) + { + return; + } + + s_audio.volume_percent = volume_percent > 100U ? 100U : volume_percent; + if (s_audio.ready && s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE) + { + (void)esp_codec_dev_set_out_vol(s_audio.codec, s_audio.volume_percent); + (void)esp_codec_dev_set_out_mute(s_audio.codec, s_audio.volume_percent == 0); + } +} diff --git a/platform/linux/common/src/platform/ui/screen_runtime.cpp b/platform/linux/common/src/platform/ui/screen_runtime.cpp index 206b91d1..2e5af78e 100644 --- a/platform/linux/common/src/platform/ui/screen_runtime.cpp +++ b/platform/linux/common/src/platform/ui/screen_runtime.cpp @@ -126,6 +126,11 @@ void enter_from_saver() wake_saver(); } +void wake_for_modal() +{ + wake_saver(); +} + void update_user_activity() { wake_saver(); diff --git a/platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp b/platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp index 1d64be73..bb33434b 100644 --- a/platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp +++ b/platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp @@ -83,6 +83,10 @@ void enter_from_saver() { } +void wake_for_modal() +{ +} + void update_user_activity() { } diff --git a/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp b/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp index 30ccac75..2354977f 100644 --- a/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp +++ b/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp @@ -54,6 +54,22 @@ void copy_hash(uint8_t (&out)[N], const std::array& value) std::memcpy(out, value.data(), N); } +std::array +announce_blob(uint8_t seed, uint64_t emitted) +{ + std::array out = {}; + for (std::size_t index = 0; index < 5; ++index) + { + out[index] = static_cast(seed + index); + } + for (std::size_t index = out.size(); index > 5; --index) + { + out[index - 1U] = static_cast(emitted & 0xFFU); + emitted >>= 8U; + } + return out; +} + } // namespace int main() @@ -66,6 +82,7 @@ int main() assert(transport.packet_filter.empty()); assert(transport.reverse_table.empty()); assert(transport.pending_path_requests.empty()); + assert(transport.pending_ping_receipts.empty()); assert(transport.link_relays.empty()); const TransportRuntimeLimits limits{ @@ -77,23 +94,72 @@ int main() 30000, 45000, 60000, - 300000}; + 300000, + 4, + 1000, + 500}; const auto destination = filled_hash(0x10); PathEntry& path = upsertPath(transport, destination.data(), limits.max_paths); path.cached_announce_len = 42; - path.hops = 2; - path.last_seen_s = 1234; + const auto first_announce = announce_blob(0xA0, 100); + assert(evaluatePathAnnounce(nullptr, + 2, + first_announce.data(), + 100, + limits.path_ttl_ms) == + PathAnnounceDecision::AcceptNew); + applyPathAnnounce(path, 2, first_announce.data(), 100, 1234); path.direct = false; assert(transport.paths.size() == 1); assert(same_hash(transport.paths.front().destination_hash, destination)); assert(transport.paths.front().cached_announce_len == 42); assert(findPath(transport, destination.data()) == &transport.paths.front()); + assert(!pathExpired(path, 1100, limits.path_ttl_ms)); + assert(pathExpired(path, 1101, limits.path_ttl_ms)); + assert(evaluatePathAnnounce(&path, + 2, + first_announce.data(), + 200, + limits.path_ttl_ms) == + PathAnnounceDecision::RejectReplay); + const auto stale_better_announce = announce_blob(0xB0, 99); + assert(evaluatePathAnnounce(&path, + 1, + stale_better_announce.data(), + 200, + limits.path_ttl_ms) == + PathAnnounceDecision::RejectStale); + const auto newer_worse_announce = announce_blob(0xC0, 101); + assert(evaluatePathAnnounce(&path, + 3, + newer_worse_announce.data(), + 200, + limits.path_ttl_ms) == + PathAnnounceDecision::AcceptNewer); + applyPathAnnounce(path, 3, newer_worse_announce.data(), 200, 1235); + assert(path.hops == 3); + assert(path.announce_timebase == 101); + assert(evaluatePathAnnounce(&path, + 1, + stale_better_announce.data(), + 1201, + limits.path_ttl_ms) == + PathAnnounceDecision::RejectStale); + assert(evaluatePathAnnounce(&path, + 4, + stale_better_announce.data(), + 1201, + limits.path_ttl_ms) == + PathAnnounceDecision::AcceptExpired); const auto packet_hash = filled_hash(0x20); rememberPacket(transport, packet_hash.data(), 100, limits.max_packet_filter); assert(same_hash(transport.packet_filter.front().packet_hash, packet_hash)); assert(isDuplicatePacket(transport, packet_hash.data())); + forgetPacket(transport, packet_hash.data()); + assert(!isDuplicatePacket(transport, packet_hash.data())); + rememberPacket(transport, packet_hash.data(), 100, limits.max_packet_filter); rememberReversePath(transport, destination.data(), 3, 200, limits.max_reverse_entries); ReverseEntry* reverse = findReversePath(transport, destination.data()); @@ -111,6 +177,21 @@ int main() resolvePendingPathRequest(transport, destination.data()); assert(findPendingPathRequest(transport, destination.data()) == nullptr); + const auto peer_sig_pub = + filled_hash(0x55); + notePendingPingReceipt(transport, + packet_hash.data(), + destination.data(), + peer_sig_pub.data(), + 350, + limits.max_pending_ping_receipts); + PendingPingReceipt* ping_receipt = + findPendingPingReceipt(transport, packet_hash.data()); + assert(ping_receipt != nullptr); + assert(same_hash(ping_receipt->packet_hash, packet_hash)); + assert(same_hash(ping_receipt->destination_hash, destination)); + assert(same_hash(ping_receipt->peer_sig_pub, peer_sig_pub)); + LinkRelayEntry& relay = upsertLinkRelay(transport, destination.data(), limits.max_link_relays); relay.initiator_hops = 1; relay.responder_hops = 2; @@ -122,8 +203,9 @@ int main() assert(transport.packet_filter.empty()); assert(transport.reverse_table.empty()); assert(transport.pending_path_requests.empty()); + assert(transport.pending_ping_receipts.empty()); assert(transport.link_relays.empty()); - assert(transport.paths.size() == 1); + assert(transport.paths.empty()); LinkRuntime links{}; assert(links.sessions.empty()); @@ -987,6 +1069,7 @@ int main() assert(text_delivery.incoming.timestamp == delivery_context.timestamp_s); assert(text_delivery.incoming.hop_limit == 0xFF); assert(text_delivery.incoming.encrypted); + assert(!text_delivery.incoming.source_unverified); assert(text_delivery.incoming.text == text_payload.content); assert(::chat::sameReticulumPeerIdentity(text_delivery.incoming.reticulum_identity, delivery_context.peer_identity)); @@ -1002,10 +1085,47 @@ int main() ::chat::MeshIncomingText popped_text{}; assert(text_queue.pop(&popped_text)); assert(popped_text.text == text_payload.content); + assert(!popped_text.source_unverified); assert(::chat::sameReticulumPeerIdentity(popped_text.reticulum_identity, delivery_context.peer_identity)); assert(popped_text.rx_meta.rssi_dbm_x10 == -710); + LxmfDeliveryContext unverified_context = delivery_context; + unverified_context.source_unverified = true; + unverified_context.peer_identity = + ::chat::makeReticulumDestinationIdentity(delivery_hash.data()); + LxmfMaterialisedText unverified_text{}; + assert(materialiseLxmfTextDelivery(text_payload, + unverified_context, + &unverified_text)); + assert(unverified_text.incoming.source_unverified); + ::chat::infra::IncomingTextQueue<1, 64> unverified_text_queue{}; + assert(unverified_text_queue.push(unverified_text.incoming, + unverified_text.text.data(), + unverified_text.text.size(), + ::chat::infra::IncomingQueuePriority::P0Critical)); + assert(unverified_text_queue.pop(&popped_text)); + assert(popped_text.source_unverified); + assert(::chat::sameReticulumPeerIdentity( + popped_text.reticulum_identity, + unverified_context.peer_identity)); + + ::chat::infra::IncomingTextQueue<1, 64> accepted_text_queue{}; + assert(accepted_text_queue.push(text_delivery.incoming, + text_delivery.text.data(), + text_delivery.text.size(), + ::chat::infra::IncomingQueuePriority::P0Critical, + &text_report)); + assert(!accepted_text_queue.push(text_delivery.incoming, + text_delivery.text.data(), + text_delivery.text.size(), + ::chat::infra::IncomingQueuePriority::P0Critical, + &text_report)); + assert(text_report.dropped_new); + assert(!text_report.dropped_existing); + assert(accepted_text_queue.pop(&popped_text)); + assert(popped_text.text == text_payload.content); + ::chat::lxmf::DecodedAppData app_payload{}; app_payload.portnum = 77; app_payload.packet_id = 0x0102; diff --git a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp index c275d09f..b34e29c7 100644 --- a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp +++ b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp @@ -1,4 +1,5 @@ #include "chat/infra/lxmf/lxmf_wire.h" +#include "chat/infra/reticulum/lxst_telephony_wire.h" #include "chat/infra/reticulum/reticulum_wire.h" #include "team/protocol/team_portnum.h" @@ -15,6 +16,7 @@ namespace { namespace lxmf = chat::lxmf; +namespace lxst = chat::reticulum::lxst; namespace reticulum = chat::reticulum; constexpr const char* kFullHashTrailMate = @@ -54,6 +56,20 @@ constexpr const char* kProofPacket = constexpr const char* kTextPayload = "94cb40934a0000000000c405547261696cc40f68656c6c6f207265746963756c756d80"; +constexpr const char* kSidebandTelemetryTextPayload = + "94cb0000000000000000c400c4008102c42a810297" + "c40403956940" + "c404017831f1" + "c40400003039" + "c40400000000" + "c40400000000" + "c40200fa" + "ce65ec8780"; +constexpr const char* kSidebandTelemetryRequestPayload = + "94cb0000000000000000c400c400810991810192ce65ec8780c3"; +constexpr const char* kLxstAvailableSignal = "81009103"; +constexpr const char* kLxstPreferredLowSignal = "810091cd012f"; +constexpr const char* kLxstCodec2Frames = "8101c4050206aabbcc"; constexpr const char* kPeerAnnounceAppData = "92c4087669636c69752d31c0"; constexpr const char* kAppDataPayload = @@ -362,6 +378,93 @@ void expectLxmfEnvelopeVectors() assert(decoded_text.content == "hello reticulum"); assert(decoded_text.fields_empty); + const std::vector sideband_telemetry = + fromHex(kSidebandTelemetryTextPayload); + assert(lxmf::unpackTextPayload(sideband_telemetry.data(), + sideband_telemetry.size(), + &decoded_text)); + assert(decoded_text.content.empty()); + assert(!decoded_text.fields_empty); + assert(decoded_text.fields.size() == 1); + assert(lxmf::findField(decoded_text, lxmf::kFieldTelemetry) != nullptr); + + lxmf::SidebandTelemetryLocation location{}; + assert(lxmf::decodeSidebandTelemetryLocation(decoded_text, &location)); + assert(location.valid); + assert(location.latitude_e6 == 60123456); + assert(location.longitude_e6 == 24654321); + assert(location.altitude_cm == 12345); + assert(location.accuracy_cm == 250); + assert(location.timestamp == 1710000000UL); + + std::vector encoded_sideband_telemetry = encodeBuffer(96); + std::size_t encoded_sideband_telemetry_len = encoded_sideband_telemetry.size(); + assert(lxmf::encodeSidebandTelemetryLocationPayload( + 0.0, + location, + encoded_sideband_telemetry.data(), + &encoded_sideband_telemetry_len)); + encoded_sideband_telemetry.resize(encoded_sideband_telemetry_len); + expectBytes(encoded_sideband_telemetry.data(), + encoded_sideband_telemetry.size(), + kSidebandTelemetryTextPayload); + + const std::vector sideband_request = + fromHex(kSidebandTelemetryRequestPayload); + assert(lxmf::unpackTextPayload(sideband_request.data(), + sideband_request.size(), + &decoded_text)); + lxmf::SidebandTelemetryRequest telemetry_request{}; + assert(lxmf::decodeSidebandTelemetryRequest(decoded_text, + &telemetry_request)); + assert(telemetry_request.valid); + assert(telemetry_request.timebase == 1710000000UL); + assert(telemetry_request.collector_request); + + std::vector lxst_wire = encodeBuffer(32); + std::size_t lxst_wire_len = lxst_wire.size(); + assert(lxst::encodeSignalling(lxst::kStatusAvailable, + lxst_wire.data(), + &lxst_wire_len)); + lxst_wire.resize(lxst_wire_len); + expectBytes(lxst_wire.data(), lxst_wire.size(), kLxstAvailableSignal); + + lxst::DecodedPacket lxst_packet{}; + assert(lxst::decodePacket(lxst_wire.data(), lxst_wire.size(), &lxst_packet)); + assert(lxst_packet.signal_count == 1); + assert(lxst_packet.signals[0] == lxst::kStatusAvailable); + + lxst_wire.assign(32, 0); + lxst_wire_len = lxst_wire.size(); + assert(lxst::encodeSignalling( + lxst::kPreferredProfile + lxst::kProfileBandwidthLow, + lxst_wire.data(), + &lxst_wire_len)); + lxst_wire.resize(lxst_wire_len); + expectBytes(lxst_wire.data(), lxst_wire.size(), kLxstPreferredLowSignal); + + const uint8_t codec2_frames[] = {0xAA, 0xBB, 0xCC}; + lxst_wire.assign(32, 0); + lxst_wire_len = lxst_wire.size(); + assert(lxst::encodeCodec2Frames( + chat::reticulum::audio_call::Codec2Mode::Mode3200, + codec2_frames, + sizeof(codec2_frames), + lxst_wire.data(), + &lxst_wire_len)); + lxst_wire.resize(lxst_wire_len); + expectBytes(lxst_wire.data(), lxst_wire.size(), kLxstCodec2Frames); + assert(lxst::decodePacket(lxst_wire.data(), lxst_wire.size(), &lxst_packet)); + assert(lxst_packet.frame_count == 1); + assert(lxst_packet.frames[0].codec == lxst::kCodec2); + assert(lxst_packet.frames[0].codec2_mode_valid); + assert(lxst_packet.frames[0].codec2_mode == + chat::reticulum::audio_call::Codec2Mode::Mode3200); + assert(lxst_packet.frames[0].encoded_len == sizeof(codec2_frames)); + assert(std::memcmp(lxst_packet.frames[0].encoded, + codec2_frames, + sizeof(codec2_frames)) == 0); + std::vector peer_announce = encodeBuffer(32); std::size_t peer_announce_len = peer_announce.size(); assert(lxmf::packPeerAnnounceAppData("vicliu-1",