From 54cf0301e2211d2aad0aaa1ce46a65caaa782d61 Mon Sep 17 00:00:00 2001 From: vicliu Date: Mon, 13 Apr 2026 17:33:21 +0800 Subject: [PATCH] Gat562 mesh evb pro (#13) * feat(gat562): wire nrf52 board runtime and bluetooth stack * refactor(chat): align shared BLE cores and nrf meshtastic routing - extract Meshtastic and MeshCore phone/business flows into shared core_chat BLE cores with owner hook seams for ESP and nRF backends - replace nrf-specific lite adapters with board/platform adapters that consume shared cores and shared storage contracts - extend nrf Meshtastic radio path with packet history dedupe, flooding rebroadcast, route discovery/traceroute responses, routing ACK/error handling, observed relay learning, retransmit queueing, and next-hop persistence - persist nrf device identity and node metadata more explicitly, including next-hop state and node-id initialization support - move nrf chat/contact/blob storage further toward InternalFS-backed persistence and update contact/node store behavior accordingly - wire gat562 protocol/app facade pieces to the concrete nrf stores/adapters and add supporting app time-sync hooks used by platform runtimes - add owner-hook split files and planning/status docs to document the ongoing alignment with the reference meshtastic-firmware multi-platform structure * Add NRF Fusion Pixel font and fix Meshtastic RX Integrate Fusion Pixel 8px as the NRF mono UI font stack and switch the mono runtime to a dedicated MonoFont/TextRenderer path that can render ASCII, CJK, and symbols from one asset set. Add the generated NRF font assets and generator script, including the compact font format with per-glyph advance support, and fix the BDF bitmap parsing bug that previously produced blank ASCII glyphs on device. Move the LVGL Noto CJK font asset out of shared modules into the ESP-specific platform tree and update the related build/config references so the ESP-only font boundary is explicit and NRF does not compile the LVGL asset by accident. Update the mono UI runtime to use the new renderer, normalize major menu and title labels to uppercase, and wire the GAT562 UI runtime to the Fusion Pixel font. Fix the core mesh adapter router so Meshtastic and MeshCore backends can coexist without overwriting each other, and ensure active protocol selection is applied before mesh config changes are pushed into the runtime. Improve GAT562 runtime diagnostics by mirroring debug output to both Serial and Serial2, logging startup radio configuration, and logging raw RX packets with RSSI/SNR so radio-path issues are visible during bring-up. Relax SX1262 receive polling so it no longer hard-gates RX handling on DIO1 state alone, avoiding a failure mode where the board appeared alive but never delivered packets into the receive path. Align the NRF Meshtastic adapter with the working ESP behavior for channel handling: use the default expanded public PSK for an unconfigured primary channel, compute channel hashes from the active preset name or Custom label instead of a hard-coded Primary string, and apply the same logic to self-announcement/NodeInfo packets. Add protocol-layer Meshtastic RX diagnostics for parse, decrypt, decode, text queueing, app-data queueing, unknown-channel, and dedup events to make packet flow visible while validating interoperability. Fix early duplicate handling for relayed packets so the first valid packet is still delivered into text processing while later relays are correctly suppressed, which restores Broadcast conversation creation and message visibility in the CHATS page. * feat(gat562): align nrf ui and meshtastic behavior * Refine mono UI GPS pages and board diagnostics * refactor: align board runtimes, GAT562 app shell, and BLE integrations - migrate ESP board-specific implementations and runtime adapters into boards/* - continue GAT562/Tab5/UI runtime alignment across app and board layers - sync BLE and runtime integrations, and apply repository clang-format rules * Refine gat562 mono UI and persist UI settings * Fix settings persistence and GNSS UI behavior * Improve mono UI paging and Meshtastic RX fallback * Align nRF52 Meshtastic BLE and PKI behavior * Refine mono UI node and chat interactions * Fix deferred config persistence on gat562 BLE * Refine mono node compass and GPS layouts * Include node position in Meshtastic phone info * Refactor shared board contracts out of ESP platform layer * Persist node positions across restarts * Ignore local compile commands database * Sync README acknowledgements updates * Apply CI formatting * Fix board ownership drift and restore pager walkie support * Add APRS asset, expand System controls, and harden GAT562 self-position persistence Bundle the current workspace changes into a single checkpoint covering the in-progress shared UI, settings, and GAT562 runtime work. What this includes: - add the APRS asset source and image resource currently used by the shared UI asset set - extend the shared System/settings flow so vibration and screen brightness can be surfaced through the device runtime and settings UI plumbing - update the mono 128x64 runtime and the GAT562 platform bindings so the new settings controls are available in the GAT562 experience - wire the ESP runtime/startup helpers needed to support the same device-runtime settings path on the ESP side - persist the GAT562 self GPS position into the node store during core service updates so a device that already obtained a fix can retain its own last known position across reboot - add an explicit node-store flush interface and implement it in both the nRF52 and ESP Meshtastic node-store backends so critical self-position updates can be forced to persistent storage immediately instead of waiting for the normal debounce window - keep the existing debounced persistence behavior for ordinary node-store churn while allowing targeted immediate flushes for data that would otherwise be easy to lose during restart or power loss * chore: checkpoint current chat and contacts changes * perf: reduce chat list and message handling latency * Align Meshtastic phone sync and chat UI refresh * Remove key verification UI and hide MC position exchange * Add T-Deck Pro board bring-up and sync pending UI fixes * Refactor GAT562 runtime and stabilize Meshtastic BLE Refactor the GAT562 board runtime by extracting dedicated GPS and input runtime components and routing config application through a focused runtime apply service. Narrow the nRF52 BLE manager dependencies, rework the Meshtastic BLE transport to better match the Android client handshake, and harden the FromRadio/FromNum flow with improved staging, read pacing, diagnostics, and stack-safe config snapshot generation. Also repair repository text encoding issues in docs and UI comments, and add Meshtastic BLE timing documentation to capture the expected Android/firmware interaction model for future debugging. * fix: gat562 settings * Define LoRa tx power caps per board * Refine mono conversation bubble focus * Polish mono conversation and message info layout * Harden Meshtastic BLE flow and cross-target builds Adjust the Meshtastic BLE transport timing on nRF52, retain pending FROMNUM notifications, and defer local admin/self replies to reduce request/response races during Android settings flows. Also make the shared chat/UI code compile across non-nRF targets by gating RTOS and Arduino-only helpers and by updating the team page layout to the current page profile fields. Note: this does not resolve the gat562 Meshtastic Android settings progress bar issue yet; the BLE/admin response path is improved and instrumented, but the root cause of the stuck loading state remains unresolved. * Fix GAT562 mono channel slot UI and screen timeout * Revert Meshtastic BLE hardening changes Revert b9e20b26622bfe409d741c3d78c211b14978fb96 because it causes app-side configuration reads to hang on GAT562. This restores the previous Meshtastic BLE timing/notification behavior and also drops the cross-target compatibility adjustments that came with that commit. * Refactor nrf52 fs helpers and show flash free space * fix sync node info * Harden Meshtastic BLE receive path * Fix Meshtastic nodeinfo persistence * Persist NodeInfo position updates * Format files to match CI style * Prepare 0.1.14-alpha docs and CI * Fix watch chat build and CI formatting * Format sources for CI clang-format-14 --- .github/workflows/ci.yml | 14 +- CHANGELOG.md | Bin 28036 -> 31984 bytes README.md | Bin 45088 -> 26272 bytes README_CN.md | Bin 24018 -> 24451 bytes apps/esp_idf/CMakeLists.txt | 2 + apps/esp_idf/src/app_facade_runtime.cpp | 1 + apps/esp_idf/src/startup_runtime.cpp | 10 + .../include/apps/esp_pio/app_context.h | 13 +- apps/esp_pio/src/app_context.cpp | 19 +- apps/esp_pio/src/startup_runtime.cpp | 11 +- .../gat562_mesh_evb_pro/app_facade_runtime.h | 16 +- .../runtime_apply_service.h | 43 + .../apps/gat562_mesh_evb_pro/ui_runtime.h | 1 - .../src/app_facade_runtime.cpp | 188 ++- .../gat562_mesh_evb_pro/src/debug_console.cpp | 11 + .../src/runtime_apply_service.cpp | 97 ++ apps/gat562_mesh_evb_pro/src/ui_runtime.cpp | 93 +- boards/T-Deck-Pro.json | 38 + boards/T-Deck.json | 5 +- .../gat562_mesh_evb_pro/board_profile.h | 8 + .../boards/gat562_mesh_evb_pro/gat562_board.h | 9 +- .../boards/gat562_mesh_evb_pro/gps_runtime.h | 54 + .../gat562_mesh_evb_pro/input_runtime.h | 47 + .../gat562_mesh_evb_pro/settings_store.h | 10 + boards/gat562_mesh_evb_pro/library.json | 4 +- .../gat562_mesh_evb_pro/src/gat562_board.cpp | 284 ++-- .../gat562_mesh_evb_pro/src/gps_runtime.cpp | 1172 +++++++++++++++++ .../gat562_mesh_evb_pro/src/input_runtime.cpp | 122 ++ .../src/platform_ui_bindings.cpp | 106 +- .../src/settings_store.cpp | 480 +++++-- boards/lilygo-t-lora-pager.json | 2 +- boards/lilygo-t-watch-s3.json | 3 +- .../include/boards/tdeck_pro/board_profile.h | 221 ++++ .../tdeck_pro/platform_esp_board_runtime.h | 36 + .../boards/tdeck_pro/tdeck_pro_board.h | 166 +++ boards/tdeck_pro/library.json | 57 + boards/tdeck_pro/src/tdeck_pro_board.cpp | 851 ++++++++++++ .../boards/tlora_pager/tlora_pager_board.h | 20 + boards/tlora_pager/src/tlora_pager_board.cpp | 122 +- docs/MESHTASTIC_PHONE_PROTOCOL_REFERENCE.md | 681 ++++++++++ docs/UI_SHARED_IDF_PHASE_PLAN.md | 413 ++---- docs/devices/gat562-mesh-evb-pro.md | 22 +- docs/devices/lilygo-t-lora-pager.md | 45 +- docs/meshtastic_ble_timing.md | 349 +++++ docs/team/persist.md | 8 +- docs/team/uiux.md | 831 +++--------- images/aprs.png | Bin 0 -> 2436 bytes .../include/chat/ble/meshtastic_defaults.h | 26 + .../include/chat/ble/meshtastic_phone_core.h | 16 + .../include/chat/domain/contact_types.h | 79 ++ .../chat/infra/meshtastic/mt_codec_pb.h | 9 + .../include/chat/infra/node_store_core.h | 7 +- .../include/chat/ports/i_chat_store.h | 7 + .../include/chat/ports/i_node_store.h | 24 +- .../include/chat/usecase/chat_service.h | 25 + .../include/chat/usecase/contact_service.h | 23 + .../src/ble/meshtastic_phone_core.cpp | 997 +++++++++++--- .../src/infra/meshtastic/mt_codec_pb.cpp | 106 +- .../src/infra/node_store_blob_format.cpp | 21 +- .../core_chat/src/infra/node_store_core.cpp | 498 ++++--- .../core_chat/src/usecase/chat_service.cpp | 149 ++- .../core_chat/src/usecase/contact_service.cpp | 112 +- modules/core_sys/include/app/app_config.h | 11 +- .../include/platform/ui/device_runtime.h | 4 + .../include/ui/mono_128x64/runtime.h | 7 + modules/ui_mono_128x64/src/runtime.cpp | 639 ++++++++- .../include/ui/chat_ui_runtime_proxy.h | 26 + .../chat/chat_conversation_components.h | 1 + .../chat/chat_message_list_components.h | 2 + .../ui/screens/chat/chat_ui_controller.h | 33 + .../chat_conversation_components_watch.h | 1 + .../ui/screens/contacts/contacts_state.h | 3 + .../ui/screens/settings/settings_state.h | 2 + modules/ui_shared/src/ui/assets/aprs.c | 165 +++ .../src/ui/chat_ui_runtime_proxy.cpp | 71 + .../menu/dashboard/dashboard_gps_widget.cpp | 39 +- .../dashboard/dashboard_recent_widget.cpp | 11 +- .../src/ui/menu/dashboard/dashboard_style.cpp | 27 +- .../ui_shared/src/ui/menu/menu_dashboard.cpp | 10 +- .../ui_shared/src/ui/menu/menu_profile.cpp | 4 +- .../ui_shared/src/ui/page/page_profile.cpp | 2 +- .../ui/screens/chat/chat_compose_layout.cpp | 34 +- .../chat/chat_conversation_components.cpp | 38 + .../screens/chat/chat_conversation_layout.cpp | 31 +- .../chat/chat_message_list_components.cpp | 113 +- .../ui/screens/chat/chat_ui_controller.cpp | 628 ++++++++- .../chat_conversation_components_watch.cpp | 21 + .../contacts/contacts_page_components.cpp | 116 +- .../screens/contacts/contacts_page_input.cpp | 16 +- .../screens/contacts/contacts_page_layout.cpp | 9 + .../contacts/contacts_page_runtime.cpp | 6 +- .../node_info/node_info_page_components.cpp | 89 +- .../node_info/node_info_page_layout.cpp | 52 +- .../settings/settings_page_components.cpp | 46 + .../src/ui/screens/team/team_page_layout.cpp | 37 +- .../include/ble/meshtastic_ble.h | 2 + .../chat/infra/mesh_adapter_router.h | 3 + .../chat/infra/meshtastic/mt_adapter.h | 3 + .../chat/infra/meshtastic/node_store.h | 2 + .../arduino_common/include/sys/event_bus.h | 27 +- .../esp/arduino_common/src/LV_Helper_v9.cpp | 66 +- .../src/app_event_runtime_support.cpp | 34 +- .../src/app_runtime_support.cpp | 51 +- .../arduino_common/src/ble/meshtastic_ble.cpp | 44 +- .../src/ble/meshtastic_ble_owner_hooks.cpp | 24 +- .../src/chat/infra/mesh_adapter_router.cpp | 8 + .../src/chat/infra/meshtastic/mt_adapter.cpp | 122 +- .../src/chat/infra/meshtastic/node_store.cpp | 10 + .../src/platform_ui_device_runtime.cpp | 20 + .../esp/arduino_common/src/screen_sleep.cpp | 47 +- .../arduino_common/src/sstv/sstv_service.cpp | 6 +- .../src/ui/screens/gps/gps_page_map.cpp | 2 +- .../src/walkie/walkie_service.cpp | 2 +- .../esp/arduino_common/src/walkie_runtime.cpp | 119 +- platform/esp/boards/src/board_runtime.cpp | 2 + .../src/display/drivers/ST7789TDeck.cpp | 3 +- .../src/display/drivers/ST7789WatchS3.cpp | 3 +- .../esp/boards/src/display/drivers/ST7796.cpp | 26 +- platform/esp/idf_common/src/gps_runtime.cpp | 33 +- .../src/platform_ui_device_runtime.cpp | 30 + platform/esp/idf_common/src/screen_sleep.cpp | 7 +- .../m5stack_tab5/m5stack_tab5.c | 2 +- .../arduino_common/include/ble/ble_manager.h | 23 +- .../include/ble/meshtastic_ble.h | 100 +- .../meshtastic/meshtastic_radio_adapter.h | 35 +- .../chat/infra/meshtastic/node_store.h | 2 + .../chat/infra/store/internal_fs_store.h | 10 + .../nrf52/arduino_common/internal_fs_utils.h | 22 + .../arduino_common/src/ble/ble_manager.cpp | 61 +- .../arduino_common/src/ble/meshtastic_ble.cpp | 1053 +++++++++++++-- .../src/chat/infra/blob_file_store.cpp | 66 +- .../meshtastic/meshtastic_radio_adapter.cpp | 645 +++++++-- .../src/chat/infra/meshtastic/node_store.cpp | 11 + .../chat/infra/store/internal_fs_store.cpp | 64 +- .../arduino_common/src/internal_fs_utils.cpp | 161 +++ .../src/platform_ui_screen_runtime.cpp | 95 ++ .../src/platform_ui_settings_store.cpp | 122 +- .../envs/gat562_mesh_evb_pro.ini | 10 +- .../lilygo_tlora_pager/envs/tlora_pager.ini | 4 + .../envs/lilygo_twatch_s3.ini | 2 + variants/tdeck/envs/tdeck.ini | 2 + variants/tdeck_pro/envs/tdeck_pro.ini | 76 ++ 142 files changed, 11784 insertions(+), 2556 deletions(-) create mode 100644 apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/runtime_apply_service.h create mode 100644 apps/gat562_mesh_evb_pro/src/runtime_apply_service.cpp create mode 100644 boards/T-Deck-Pro.json create mode 100644 boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h create mode 100644 boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/input_runtime.h create mode 100644 boards/gat562_mesh_evb_pro/src/gps_runtime.cpp create mode 100644 boards/gat562_mesh_evb_pro/src/input_runtime.cpp create mode 100644 boards/tdeck_pro/include/boards/tdeck_pro/board_profile.h create mode 100644 boards/tdeck_pro/include/boards/tdeck_pro/platform_esp_board_runtime.h create mode 100644 boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h create mode 100644 boards/tdeck_pro/library.json create mode 100644 boards/tdeck_pro/src/tdeck_pro_board.cpp create mode 100644 docs/MESHTASTIC_PHONE_PROTOCOL_REFERENCE.md create mode 100644 docs/meshtastic_ble_timing.md create mode 100644 images/aprs.png create mode 100644 modules/core_chat/include/chat/ble/meshtastic_defaults.h create mode 100644 modules/ui_shared/include/ui/chat_ui_runtime_proxy.h create mode 100644 modules/ui_shared/src/ui/assets/aprs.c create mode 100644 modules/ui_shared/src/ui/chat_ui_runtime_proxy.cpp create mode 100644 platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/internal_fs_utils.h create mode 100644 platform/nrf52/arduino_common/src/internal_fs_utils.cpp create mode 100644 platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp create mode 100644 variants/tdeck_pro/envs/tdeck_pro.ini diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99911eaf..bab3507a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - tlora_pager_sx1262 - tdeck - lilygo_twatch_s3 + - gat562_mesh_evb_pro steps: - name: Checkout uses: actions/checkout@v4 @@ -77,13 +78,18 @@ jobs: - name: Collect firmware run: | mkdir -p dist - cp .pio/build/${{ matrix.env }}/firmware.bin dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.bin + if [ "${{ matrix.env }}" = "gat562_mesh_evb_pro" ]; then + cp .pio/build/${{ matrix.env }}/firmware.hex dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.hex + cp .pio/build/${{ matrix.env }}/firmware.zip dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.zip + else + cp .pio/build/${{ matrix.env }}/firmware.bin dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.bin + fi - name: Upload firmware artifact uses: actions/upload-artifact@v4 with: name: trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }} - path: dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.bin + path: dist/trail-mate-${{ matrix.env }}-v${{ steps.version.outputs.version }}.* release: name: Publish GitHub Release @@ -102,7 +108,7 @@ jobs: - name: Verify collected firmware files run: | - find dist -type f -name '*.bin' -print | sort + find dist -type f \( -name '*.bin' -o -name '*.hex' -o -name '*.zip' \) -print | sort - name: Create or update release uses: softprops/action-gh-release@v2 @@ -114,3 +120,5 @@ jobs: overwrite_files: true files: | dist/**/*.bin + dist/**/*.hex + dist/**/*.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d9506727c6e8398ec3f7971e6041d3c71c50b3..40e23b06fe238c091ac680623d4681da0863caa7 100644 GIT binary patch delta 3398 zcma)9TWnNS6g^{sVrfTfX?Y0t7L-=PSYBe%7_>mCM0%&wsg!1*uj!q3XlFW^nNAA` zH{jr_!9tFW#19RL1fwx&f|~Hdho2-EiBY2j6F-c9JWSI_MeADU^mc}66HGJQd(UH^ zz1P}%?L+#8>&`vb_~awY3Peg+A}$glECxgr&*y|83|XA_lB+=Ci{H?+x zHi<2=wxYbg0PlvV7qy~UJTJD1&G>47m4xWUTba%3|)@NQzU`3}|W8m}wF@*0fF^CoYSjV|2_820Du`Y~-)hV=N;5~|ecZxk?gId|G ztVczUyj#4?*5_KVkifYHY!AXB?IbZ`h!95NcqZXB=cA%Y>_$9{CgU^b>0b}l(4T7j z#;uR`KQ#W+QymA9RB| zZ>92k)2R}(YLFJ>=#)>MS^c!_U}cG6#6(ND2M%xK0=P8m{9@<0ouoh<0$CyXSU>m8P|eSz&NHNmXO)NMAAI{$`5Sl*ne!TCrXz~NK` zogzVXXhON(2@;28| z*Tx6keAN(c*U!58a;V3Mb1j16Noiqz?2;e4EsWG zoAvMew@+8KY_m2t&F5=bSGK2<*8RZ53GacM-brg>jC?srd>wanTss@JQEp@B&S^2N zX+JhP^v31Uy@NNspLFTe_}%(xriyU)kUy_0DV~FmbQvpV1v7#0;y^;9LEgv?!yZuB z1r?e)2b@0Ir38d%4&z5jB@~(kkK&9BP)x}ek!CXsiz9f>i0aE~l0#hPD7-ctQ=8cF zGD@!9VnNT)CT}k-ZJ`Sk2CxGwbcH)utn3@QEqB8K3P()M(^V}KRCD%V=i6zPa9y4^ zO68#iZhH=N;@a8jW_R-%b5pBBL`83~@8rqLvHgKh8%NugHI?}*TOT{%bUP-%MepbD z{bRNSzUoV-T){fC%&%px5$Ef^?(`YTKNH?S3=z?_1`poevhQ^{E=k|268TNZ6BhC9 z!%C-Sn6gBjA#yZVmJIM7Q)>uVLTO$Ct20dMh8a{Y&D?@z_0DCsZn2#TQmu<5Fr5EHhSg-ZJ6i5E(3+ScNdtv#a6Kebag1dk|-yAk>tfRqE67)Ra)RRmX`u#xUE{X z#xmOhLlorn)7@&5h9KP3>OFE~V(hB_uT;W!Wh83<<{ju@8`&PJIb1kxpK0(Nef>qR z0e2pB4%dRz?UDO|%8paM{ekVFyqd%6KR@zN-g>#C;wh&^$Y1ilz4Y0FUtO2pT{IQ? E52Z^6{r~^~ delta 84 zcmezHldCv5hP)>BSfygmNCSHck-$Z|<0RRyb82bPK diff --git a/README.md b/README.md index a6f86f20af5d06f93f6dabbe9ac568d8783fc5fb..aceff073bf863bd5837f8329d59b85337364fc4c 100644 GIT binary patch literal 26272 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 literal 45088 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 o$cD4LdR?+@@M`_nclosQrDVHwq@e(u;muhze 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 Ms6p+)(XnO!0b{E5EdT%j literal 24018 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 diff --git a/apps/esp_idf/CMakeLists.txt b/apps/esp_idf/CMakeLists.txt index d333a335..c97a0ec9 100644 --- a/apps/esp_idf/CMakeLists.txt +++ b/apps/esp_idf/CMakeLists.txt @@ -194,7 +194,9 @@ set(trail_mate_idf_ui_shared_sources set(trail_mate_idf_include_dirs "include" + "${CMAKE_SOURCE_DIR}/platform/shared/include" "${CMAKE_SOURCE_DIR}/boards/tab5/include" + "${CMAKE_SOURCE_DIR}/boards/t_display_p4/include" "${CMAKE_SOURCE_DIR}/modules/ui_shared/include" "${CMAKE_SOURCE_DIR}/modules/core_sys/include" "${CMAKE_SOURCE_DIR}/modules/core_chat/include" diff --git a/apps/esp_idf/src/app_facade_runtime.cpp b/apps/esp_idf/src/app_facade_runtime.cpp index c02e4792..583f393d 100644 --- a/apps/esp_idf/src/app_facade_runtime.cpp +++ b/apps/esp_idf/src/app_facade_runtime.cpp @@ -976,6 +976,7 @@ class MinimalAppFacade final : public app::IAppFacade if (chat_service_) { chat_service_->processIncoming(); + chat_service_->flushStore(); } if (team_pairing_) diff --git a/apps/esp_idf/src/startup_runtime.cpp b/apps/esp_idf/src/startup_runtime.cpp index d6f9ba6c..7b302839 100644 --- a/apps/esp_idf/src/startup_runtime.cpp +++ b/apps/esp_idf/src/startup_runtime.cpp @@ -5,6 +5,7 @@ #include "app/app_config.h" #include "app/app_facade_access.h" +#include "board/BoardBase.h" #include "boards/tab5/rtc_runtime.h" #include "esp_log.h" #include "platform/esp/boards/board_runtime.h" @@ -12,9 +13,11 @@ #include "platform/esp/idf_common/gps_runtime.h" #include "platform/esp/idf_common/startup_support.h" #include "platform/esp/idf_common/sx126x_radio.h" +#include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/lora_runtime.h" #include "platform/ui/screen_runtime.h" +#include "platform/ui/settings_store.h" #include "ui/app_registry.h" #include "ui/app_runtime.h" #include "ui/startup_shell.h" @@ -105,7 +108,14 @@ ui::startup_shell::Hooks buildShellHooks() hooks.watch_face = ui::startup_shell::defaultWatchFaceHooks(); hooks.set_max_brightness = []() { + const int saved = platform::ui::settings_store::get_int("settings", "screen_brightness", + DEVICE_MAX_BRIGHTNESS_LEVEL); + const int clamped = + saved < DEVICE_MIN_BRIGHTNESS_LEVEL + ? DEVICE_MIN_BRIGHTNESS_LEVEL + : (saved > DEVICE_MAX_BRIGHTNESS_LEVEL ? DEVICE_MAX_BRIGHTNESS_LEVEL : saved); (void)platform::esp::idf_common::bsp_runtime::wake_display(); + platform::ui::device::set_screen_brightness(static_cast(clamped)); }; return hooks; } diff --git a/apps/esp_pio/include/apps/esp_pio/app_context.h b/apps/esp_pio/include/apps/esp_pio/app_context.h index 0064319b..1b34094a 100644 --- a/apps/esp_pio/include/apps/esp_pio/app_context.h +++ b/apps/esp_pio/include/apps/esp_pio/app_context.h @@ -38,6 +38,7 @@ class ContactService; namespace ui { class IChatUiRuntime; +class GlobalChatUiRuntime; } // namespace ui } // namespace chat @@ -79,15 +80,9 @@ class AppContext final : public IAppBleFacade { return *contact_service_; } - chat::ui::IChatUiRuntime* getChatUiRuntime() override - { - return chat_ui_runtime_; - } + chat::ui::IChatUiRuntime* getChatUiRuntime() override; - void setChatUiRuntime(chat::ui::IChatUiRuntime* runtime) override - { - chat_ui_runtime_ = runtime; - } + void setChatUiRuntime(chat::ui::IChatUiRuntime* runtime) override; team::TeamController* getTeamController() override { @@ -296,7 +291,7 @@ class AppContext final : public IAppBleFacade std::unique_ptr team_pairing_transport_; std::unique_ptr team_pairing_service_; - chat::ui::IChatUiRuntime* chat_ui_runtime_ = nullptr; + std::unique_ptr chat_ui_runtime_proxy_; std::unique_ptr ble_manager_; AppEventRuntimeHooks event_runtime_hooks_{}; diff --git a/apps/esp_pio/src/app_context.cpp b/apps/esp_pio/src/app_context.cpp index a6aa240c..54f568ce 100644 --- a/apps/esp_pio/src/app_context.cpp +++ b/apps/esp_pio/src/app_context.cpp @@ -12,6 +12,7 @@ #include "chat/infra/mesh_protocol_utils.h" #include "chat/runtime/self_identity_policy.h" #include "sys/event_bus.h" +#include "ui/chat_ui_runtime_proxy.h" #ifdef USING_ST25R3916 #endif #include @@ -26,7 +27,10 @@ AppContext& AppContext::getInstance() return instance; } -AppContext::AppContext() = default; +AppContext::AppContext() + : chat_ui_runtime_proxy_(new chat::ui::GlobalChatUiRuntime()) +{ +} AppContext::~AppContext() = default; @@ -165,6 +169,19 @@ const chat::IMeshAdapter* AppContext::getMeshAdapter() const return mesh_router_.get(); } +chat::ui::IChatUiRuntime* AppContext::getChatUiRuntime() +{ + return chat_ui_runtime_proxy_.get(); +} + +void AppContext::setChatUiRuntime(chat::ui::IChatUiRuntime* runtime) +{ + if (chat_ui_runtime_proxy_) + { + chat_ui_runtime_proxy_->setActiveRuntime(runtime); + } +} + void AppContext::saveConfig() { if (platform_bindings_.save_app_config) diff --git a/apps/esp_pio/src/startup_runtime.cpp b/apps/esp_pio/src/startup_runtime.cpp index 64853d2a..abe83479 100644 --- a/apps/esp_pio/src/startup_runtime.cpp +++ b/apps/esp_pio/src/startup_runtime.cpp @@ -8,6 +8,7 @@ #include "display/DisplayConfig.h" #include "platform/esp/arduino_common/display_runtime.h" #include "platform/esp/arduino_common/startup_support.h" +#include "platform/ui/settings_store.h" #include "ui/app_registry.h" #include "ui/app_runtime.h" #include "ui/startup_shell.h" @@ -21,7 +22,15 @@ void initializeShell() hooks.messaging = &app::messagingFacade(); hooks.apps = ui::appCatalog(); hooks.set_max_brightness = []() - { board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL); }; + { + const int saved = platform::ui::settings_store::get_int("settings", "screen_brightness", + DEVICE_MAX_BRIGHTNESS_LEVEL); + const int clamped = + saved < DEVICE_MIN_BRIGHTNESS_LEVEL + ? DEVICE_MIN_BRIGHTNESS_LEVEL + : (saved > DEVICE_MAX_BRIGHTNESS_LEVEL ? DEVICE_MAX_BRIGHTNESS_LEVEL : saved); + board.setBrightness(static_cast(clamped)); + }; hooks.show_main_menu = menu_show; hooks.watch_face = ui::startup_shell::defaultWatchFaceHooks(); ui::startup_shell::initializeShell(hooks); diff --git a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h index 2589bbbe..919775b2 100644 --- a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h +++ b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/app_facade_runtime.h @@ -2,6 +2,7 @@ #include "app/app_config.h" #include "app/app_facades.h" +#include "ble/ble_manager.h" #include "chat/runtime/self_identity_policy.h" #include "chat/runtime/self_identity_provider.h" @@ -34,7 +35,10 @@ class Gat562Board; namespace apps::gat562_mesh_evb_pro { -class AppFacadeRuntime final : public app::IAppBleFacade +class RuntimeApplyService; + +class AppFacadeRuntime final : public app::IAppBleFacade, + public ble::IBleRuntimeContext { public: static AppFacadeRuntime& instance(); @@ -95,6 +99,13 @@ class AppFacadeRuntime final : public app::IAppBleFacade void tickEventRuntime() override; void dispatchPendingEvents(std::size_t max_events = 32) override; + const app::AppConfig& bleConfig() const override; + bool bleEnabled() const override; + void bleEffectiveUserInfo(char* out_long, std::size_t long_len, + char* out_short, std::size_t short_len) const override; + chat::NodeId bleSelfNodeId() const override; + app::IAppBleFacade& bleAppFacade() override; + const chat::runtime::EffectiveSelfIdentity& effectiveIdentity() const; private: @@ -103,6 +114,7 @@ class AppFacadeRuntime final : public app::IAppBleFacade void initializeStores(); void initializeChatRuntime(); void refreshEffectiveIdentity(); + void syncSelfPositionFromGps(); chat::NodeId resolveSelfNodeId() const; const chat::runtime::SelfIdentityProvider* identityProvider() const; @@ -119,9 +131,11 @@ class AppFacadeRuntime final : public app::IAppBleFacade std::unique_ptr mesh_router_; std::unique_ptr chat_service_; std::unique_ptr ble_manager_; + std::unique_ptr apply_service_; boards::gat562_mesh_evb_pro::Gat562Board* board_ = nullptr; chat::ui::IChatUiRuntime* chat_ui_runtime_ = nullptr; bool config_save_pending_ = false; + uint32_t last_chat_store_flush_ms_ = 0; }; } // namespace apps::gat562_mesh_evb_pro diff --git a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/runtime_apply_service.h b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/runtime_apply_service.h new file mode 100644 index 00000000..9af427a0 --- /dev/null +++ b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/runtime_apply_service.h @@ -0,0 +1,43 @@ +#pragma once + +#include "app/app_config.h" +#include "chat/runtime/self_identity_policy.h" + +namespace ble +{ +class BleManager; +} + +namespace chat +{ +class ChatService; +class IMeshAdapter; +} // namespace chat + +namespace boards::gat562_mesh_evb_pro +{ +class Gat562Board; +} + +namespace apps::gat562_mesh_evb_pro +{ + +class RuntimeApplyService +{ + public: + void applyMesh(app::AppConfig& config, + chat::IMeshAdapter* mesh_router, + chat::ChatService* chat_service, + ble::BleManager* ble_manager, + boards::gat562_mesh_evb_pro::Gat562Board* board) const; + + void applyUserInfo(const chat::runtime::EffectiveSelfIdentity& previous_identity, + const chat::runtime::EffectiveSelfIdentity& current_identity, + chat::IMeshAdapter* mesh_router, + ble::BleManager* ble_manager) const; + + void applyPosition(const app::AppConfig& config, + boards::gat562_mesh_evb_pro::Gat562Board* board) const; +}; + +} // namespace apps::gat562_mesh_evb_pro diff --git a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/ui_runtime.h b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/ui_runtime.h index 5c441829..ac06c19c 100644 --- a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/ui_runtime.h +++ b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/ui_runtime.h @@ -4,7 +4,6 @@ namespace apps::gat562_mesh_evb_pro::ui_runtime { - bool initialize(); void bindChatObservers(); void appendBootLog(const char* line); diff --git a/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp b/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp index 39410f03..3840d3ad 100644 --- a/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp +++ b/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp @@ -3,7 +3,7 @@ #include "app/app_facade_access.h" #include "apps/gat562_mesh_evb_pro/debug_console.h" #include "apps/gat562_mesh_evb_pro/protocol_factory.h" -#include "ble/ble_manager.h" +#include "apps/gat562_mesh_evb_pro/runtime_apply_service.h" #include "boards/gat562_mesh_evb_pro/gat562_board.h" #include "boards/gat562_mesh_evb_pro/settings_store.h" #include "chat/domain/chat_model.h" @@ -14,15 +14,18 @@ #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "platform/nrf52/arduino_common/chat/infra/contact_store.h" +#include "platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h" #include "platform/nrf52/arduino_common/chat/infra/meshtastic/node_store.h" #include "platform/nrf52/arduino_common/chat/infra/radio_packet_io.h" #include "platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h" #include "platform/nrf52/arduino_common/device_identity.h" #include "platform/nrf52/arduino_common/self_identity_bridge.h" +#include "sys/clock.h" #include #include +#include #include #include @@ -30,6 +33,20 @@ namespace apps::gat562_mesh_evb_pro { namespace { +constexpr uint32_t kChatStoreFlushIntervalMs = 2000UL; + +platform::nrf52::arduino_common::chat::meshtastic::MeshtasticRadioAdapter* getMeshtasticBackend(chat::IMeshAdapter* adapter) +{ + if (!adapter) + { + return nullptr; + } + + chat::IMeshAdapter* backend = adapter->backendForProtocol(chat::MeshProtocol::Meshtastic); + return backend + ? static_cast(backend) + : nullptr; +} template void copyString(const char* src, T* dst, size_t dst_len) @@ -50,9 +67,23 @@ void copyString(const char* src, T* dst, size_t dst_len) dst[copy_len] = '\0'; } -const char* protocolLabel(chat::MeshProtocol protocol) +bool buildNodePositionFromGpsState(const ::gps::GpsState& gps_state, + ::chat::contacts::NodePosition* out_position) { - return protocol == chat::MeshProtocol::MeshCore ? "MC" : "MT"; + if (!out_position || !gps_state.valid) + { + return false; + } + + ::chat::contacts::NodePosition position{}; + position.valid = true; + position.latitude_i = static_cast(std::lround(gps_state.lat * 1e7)); + position.longitude_i = static_cast(std::lround(gps_state.lng * 1e7)); + position.has_altitude = gps_state.has_alt; + position.altitude = gps_state.has_alt ? static_cast(std::lround(gps_state.alt_m)) : 0; + position.timestamp = ::sys::epoch_seconds_now(); + *out_position = position; + return true; } } // namespace @@ -63,7 +94,10 @@ AppFacadeRuntime& AppFacadeRuntime::instance() return runtime; } -AppFacadeRuntime::AppFacadeRuntime() = default; +AppFacadeRuntime::AppFacadeRuntime() + : apply_service_(new RuntimeApplyService()) +{ +} AppFacadeRuntime::~AppFacadeRuntime() = default; @@ -79,7 +113,6 @@ bool AppFacadeRuntime::initialize() { (void)board_->begin(); } - (void)::boards::gat562_mesh_evb_pro::settings_store::loadAppConfig(config_); ::boards::gat562_mesh_evb_pro::settings_store::normalizeConfig(config_); initializeStores(); @@ -253,75 +286,41 @@ void AppFacadeRuntime::saveConfig() applyPrivacyConfig(); debug_console::printf("[gat562][cfg] save post-applyPrivacy\n"); applyChatDefaults(); + ::boards::gat562_mesh_evb_pro::settings_store::queueSaveAppConfig(config_); config_save_pending_ = true; debug_console::printf("[gat562][cfg] save deferred-store queued\n"); } void AppFacadeRuntime::applyMeshConfig() { - debug_console::printf("[gat562][cfg] applyMesh start proto=%u ok_to_mqtt=%u ignore_mqtt=%u\n", - static_cast(config_.mesh_protocol), - config_.meshtastic_config.config_ok_to_mqtt ? 1U : 0U, - config_.meshtastic_config.ignore_mqtt ? 1U : 0U); - ::boards::gat562_mesh_evb_pro::settings_store::normalizeConfig(config_); - if (mesh_router_) + if (apply_service_) { - auto* router = static_cast(mesh_router_.get()); - router->setActiveProtocol(config_.mesh_protocol); + apply_service_->applyMesh(config_, + mesh_router_.get(), + chat_service_.get(), + ble_manager_.get(), + board_); } - if (mesh_router_) - { - mesh_router_->applyConfig(config_.activeMeshConfig()); - } - if (board_) - { - board_->applyRadioConfig(config_.mesh_protocol, config_.activeMeshConfig()); - } - if (chat_service_) - { - chat_service_->setActiveProtocol(config_.mesh_protocol); - } - if (ble_manager_) - { - ble_manager_->applyProtocol(config_.mesh_protocol); - } - debug_console::printf("[gat562][cfg] applyMesh end\n"); - - const chat::MeshConfig& mesh = config_.activeMeshConfig(); - debug_console::printf("[gat562] radio cfg %s region=%u preset=%u ch=%u tx=%d hop=%u\n", - protocolLabel(config_.mesh_protocol), - static_cast(mesh.region), - static_cast(mesh.modem_preset), - static_cast(mesh.channel_num), - static_cast(mesh.tx_power), - static_cast(mesh.hop_limit)); } void AppFacadeRuntime::applyUserInfo() { const chat::runtime::EffectiveSelfIdentity previous_identity = effective_identity_; refreshEffectiveIdentity(); - if (mesh_router_) + if (apply_service_) { - mesh_router_->setUserInfo(effective_identity_.long_name, - effective_identity_.short_name); - } - - const bool ble_identity_changed = - std::strcmp(previous_identity.long_name, effective_identity_.long_name) != 0 || - std::strcmp(previous_identity.short_name, effective_identity_.short_name) != 0; - if (ble_identity_changed && ble_manager_ && ble_manager_->isEnabled()) - { - ble_manager_->setEnabled(false); - ble_manager_->setEnabled(true); + apply_service_->applyUserInfo(previous_identity, + effective_identity_, + mesh_router_.get(), + ble_manager_.get()); } } void AppFacadeRuntime::applyPositionConfig() { - if (board_) + if (apply_service_) { - board_->applyGpsConfig(config_); + apply_service_->applyPosition(config_, board_); } } @@ -511,7 +510,8 @@ void AppFacadeRuntime::setBleEnabled(bool enabled) { ble_manager_->setEnabled(enabled); } - (void)::boards::gat562_mesh_evb_pro::settings_store::saveAppConfig(config_); + ::boards::gat562_mesh_evb_pro::settings_store::queueSaveAppConfig(config_); + config_save_pending_ = true; } void AppFacadeRuntime::restartDevice() @@ -590,9 +590,24 @@ const BoardBase* AppFacadeRuntime::getBoard() const void AppFacadeRuntime::updateCoreServices() { + syncSelfPositionFromGps(); if (chat_service_) { chat_service_->processIncoming(); + const uint32_t now_ms = ::sys::millis_now(); + if ((now_ms - last_chat_store_flush_ms_) >= kChatStoreFlushIntervalMs) + { + chat_service_->flushStore(); + if (node_store_) + { + (void)node_store_->flush(); + } + if (auto* mt = getMeshtasticBackend(getMeshAdapter())) + { + mt->flushDeferredPersistence(false); + } + last_chat_store_flush_ms_ = now_ms; + } } if (ble_manager_) { @@ -600,6 +615,32 @@ void AppFacadeRuntime::updateCoreServices() } } +void AppFacadeRuntime::syncSelfPositionFromGps() +{ + if (!contact_service_ || !board_ || effective_identity_.node_id == 0) + { + return; + } + + ::chat::contacts::NodePosition position{}; + if (!buildNodePositionFromGpsState(board_->gpsData(), &position)) + { + return; + } + + const ::chat::contacts::NodeInfo* existing = contact_service_->getNodeInfo(effective_identity_.node_id); + if (existing && existing->position.valid && + existing->position.latitude_i == position.latitude_i && + existing->position.longitude_i == position.longitude_i && + existing->position.has_altitude == position.has_altitude && + existing->position.altitude == position.altitude) + { + return; + } + + contact_service_->updateNodePosition(effective_identity_.node_id, position); +} + void AppFacadeRuntime::tickEventRuntime() { if (!config_save_pending_) @@ -607,13 +648,18 @@ void AppFacadeRuntime::tickEventRuntime() return; } - debug_console::printf("[gat562][cfg] deferred-store start\n"); - const bool ok = ::boards::gat562_mesh_evb_pro::settings_store::saveAppConfig(config_); - debug_console::printf("[gat562][cfg] deferred-store done ok=%u\n", ok ? 1U : 0U); - if (ok) + if (!::boards::gat562_mesh_evb_pro::settings_store::hasDeferredSavePending()) { config_save_pending_ = false; + return; } + + const bool flushed = ::boards::gat562_mesh_evb_pro::settings_store::tickDeferredSave(); + if (flushed) + { + debug_console::printf("[gat562][cfg] deferred-store flush ok\n"); + } + config_save_pending_ = ::boards::gat562_mesh_evb_pro::settings_store::hasDeferredSavePending(); } void AppFacadeRuntime::dispatchPendingEvents(std::size_t max_events) @@ -621,6 +667,32 @@ void AppFacadeRuntime::dispatchPendingEvents(std::size_t max_events) (void)max_events; } +const app::AppConfig& AppFacadeRuntime::bleConfig() const +{ + return config_; +} + +bool AppFacadeRuntime::bleEnabled() const +{ + return isBleEnabled(); +} + +void AppFacadeRuntime::bleEffectiveUserInfo(char* out_long, std::size_t long_len, + char* out_short, std::size_t short_len) const +{ + getEffectiveUserInfo(out_long, long_len, out_short, short_len); +} + +chat::NodeId AppFacadeRuntime::bleSelfNodeId() const +{ + return getSelfNodeId(); +} + +app::IAppBleFacade& AppFacadeRuntime::bleAppFacade() +{ + return *this; +} + const chat::runtime::EffectiveSelfIdentity& AppFacadeRuntime::effectiveIdentity() const { return effective_identity_; diff --git a/apps/gat562_mesh_evb_pro/src/debug_console.cpp b/apps/gat562_mesh_evb_pro/src/debug_console.cpp index b8d70c70..965809b1 100644 --- a/apps/gat562_mesh_evb_pro/src/debug_console.cpp +++ b/apps/gat562_mesh_evb_pro/src/debug_console.cpp @@ -1,6 +1,7 @@ #include "apps/gat562_mesh_evb_pro/debug_console.h" #include +#include #include #include @@ -11,12 +12,22 @@ namespace constexpr unsigned long kBaudRate = 115200UL; +bool usbSerialWritable(std::size_t len) +{ + return static_cast(Serial) && Serial.dtr() != 0 && Serial.availableForWrite() >= static_cast(len); +} + void writeToUsbSerial(const char* text) { if (!text) { return; } + const std::size_t len = std::strlen(text); + if (!usbSerialWritable(len)) + { + return; + } Serial.print(text); } diff --git a/apps/gat562_mesh_evb_pro/src/runtime_apply_service.cpp b/apps/gat562_mesh_evb_pro/src/runtime_apply_service.cpp new file mode 100644 index 00000000..0a18a9b9 --- /dev/null +++ b/apps/gat562_mesh_evb_pro/src/runtime_apply_service.cpp @@ -0,0 +1,97 @@ +#include "apps/gat562_mesh_evb_pro/runtime_apply_service.h" + +#include "apps/gat562_mesh_evb_pro/debug_console.h" +#include "ble/ble_manager.h" +#include "boards/gat562_mesh_evb_pro/gat562_board.h" +#include "boards/gat562_mesh_evb_pro/settings_store.h" +#include "chat/infra/mesh_adapter_router_core.h" +#include "chat/infra/mesh_protocol_utils.h" +#include "chat/usecase/chat_service.h" + +#include + +namespace apps::gat562_mesh_evb_pro +{ +namespace +{ + +const char* protocolLabel(chat::MeshProtocol protocol) +{ + return protocol == chat::MeshProtocol::MeshCore ? "MC" : "MT"; +} + +} // namespace + +void RuntimeApplyService::applyMesh(app::AppConfig& config, + chat::IMeshAdapter* mesh_router, + chat::ChatService* chat_service, + ble::BleManager* ble_manager, + boards::gat562_mesh_evb_pro::Gat562Board* board) const +{ + debug_console::printf("[gat562][cfg] applyMesh start proto=%u ok_to_mqtt=%u ignore_mqtt=%u\n", + static_cast(config.mesh_protocol), + config.meshtastic_config.config_ok_to_mqtt ? 1U : 0U, + config.meshtastic_config.ignore_mqtt ? 1U : 0U); + ::boards::gat562_mesh_evb_pro::settings_store::normalizeConfig(config); + + if (mesh_router) + { + auto* router = static_cast(mesh_router); + router->setActiveProtocol(config.mesh_protocol); + mesh_router->applyConfig(config.activeMeshConfig()); + } + if (board) + { + board->applyRadioConfig(config.mesh_protocol, config.activeMeshConfig()); + } + if (chat_service) + { + chat_service->setActiveProtocol(config.mesh_protocol); + } + if (ble_manager) + { + ble_manager->applyProtocol(config.mesh_protocol); + } + + debug_console::printf("[gat562][cfg] applyMesh end\n"); + + const chat::MeshConfig& mesh = config.activeMeshConfig(); + debug_console::printf("[gat562] radio cfg %s region=%u preset=%u ch=%u tx=%d hop=%u\n", + protocolLabel(config.mesh_protocol), + static_cast(mesh.region), + static_cast(mesh.modem_preset), + static_cast(mesh.channel_num), + static_cast(mesh.tx_power), + static_cast(mesh.hop_limit)); +} + +void RuntimeApplyService::applyUserInfo(const chat::runtime::EffectiveSelfIdentity& previous_identity, + const chat::runtime::EffectiveSelfIdentity& current_identity, + chat::IMeshAdapter* mesh_router, + ble::BleManager* ble_manager) const +{ + if (mesh_router) + { + mesh_router->setUserInfo(current_identity.long_name, current_identity.short_name); + } + + const bool ble_identity_changed = + std::strcmp(previous_identity.long_name, current_identity.long_name) != 0 || + std::strcmp(previous_identity.short_name, current_identity.short_name) != 0; + if (ble_identity_changed && ble_manager && ble_manager->isEnabled()) + { + ble_manager->setEnabled(false); + ble_manager->setEnabled(true); + } +} + +void RuntimeApplyService::applyPosition(const app::AppConfig& config, + boards::gat562_mesh_evb_pro::Gat562Board* board) const +{ + if (board) + { + board->applyGpsConfig(config); + } +} + +} // namespace apps::gat562_mesh_evb_pro diff --git a/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp b/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp index a54ea52a..1a6077d0 100644 --- a/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp +++ b/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp @@ -3,6 +3,7 @@ #include "apps/gat562_mesh_evb_pro/app_facade_runtime.h" #include "apps/gat562_mesh_evb_pro/debug_console.h" #include "boards/gat562_mesh_evb_pro/gat562_board.h" +#include "platform/nrf52/arduino_common/internal_fs_utils.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/time_runtime.h" @@ -13,6 +14,7 @@ #include #include #include +#include namespace apps::gat562_mesh_evb_pro::ui_runtime { @@ -22,13 +24,20 @@ using Adafruit_LittleFS_Namespace::File; using boards::gat562_mesh_evb_pro::BoardInputEvent; using boards::gat562_mesh_evb_pro::BoardInputKey; constexpr uint32_t kProbeHoldMs = 900; -constexpr uint32_t kGat562TotalRamBytes = 248832U; constexpr uint32_t kGat562FsTotalBytes = 7U * 4096U; const char kProbeAscii[] = "ABC123"; const char kProbeCjk[] = "\xE4\xB8\xAD\xE6\x96\x87"; const char kProbeSymbols[] = "\xE2\x94\x80\xE2\x96\x88\xE2\x96\xA0"; -extern "C" char* sbrk(int incr); +extern "C" +{ + extern uint32_t __data_start__[]; + extern unsigned char __HeapBase[]; + extern unsigned char __HeapLimit[]; + extern uint32_t __StackTop[]; + extern uint32_t __StackLimit[]; + int dbgStackUsed(void); +} uint32_t now_ms() { return millis(); } time_t utc_now() { return static_cast(sys::epoch_seconds_now()); } @@ -47,52 +56,56 @@ ui::mono_128x64::HostCallbacks::ResourceUsage ram_usage() { ui::mono_128x64::HostCallbacks::ResourceUsage usage{}; usage.available = true; - usage.total_bytes = kGat562TotalRamBytes; + const uintptr_t ram_begin = reinterpret_cast(__data_start__); + const uintptr_t heap_begin = reinterpret_cast(__HeapBase); + const uintptr_t heap_end = reinterpret_cast(__HeapLimit); + const uintptr_t stack_begin = reinterpret_cast(__StackLimit); + const uintptr_t stack_end = reinterpret_cast(__StackTop); - char stack_marker = 0; - const uintptr_t stack_ptr = reinterpret_cast(&stack_marker); - const uintptr_t heap_ptr = reinterpret_cast(sbrk(0)); - if (stack_ptr > heap_ptr) + if (heap_begin <= ram_begin || heap_end < heap_begin || stack_end <= stack_begin || stack_end <= ram_begin) { - const uint32_t free_bytes = static_cast(stack_ptr - heap_ptr); - usage.used_bytes = usage.total_bytes > free_bytes ? (usage.total_bytes - free_bytes) : 0U; + return usage; + } + + const uint32_t static_bytes = static_cast(heap_begin - ram_begin); + const uint32_t heap_total_bytes = static_cast(heap_end - heap_begin); + const uint32_t isr_stack_total_bytes = static_cast(stack_end - stack_begin); + usage.total_bytes = static_cast(stack_end - ram_begin); + + struct mallinfo heap_info = mallinfo(); + uint32_t heap_used_bytes = 0; + if (heap_info.uordblks > 0) + { + heap_used_bytes = static_cast(heap_info.uordblks); + if (heap_used_bytes > heap_total_bytes) + { + heap_used_bytes = heap_total_bytes; + } + } + + int isr_stack_used = dbgStackUsed(); + uint32_t isr_stack_used_bytes = 0; + if (isr_stack_used > 0) + { + isr_stack_used_bytes = static_cast(isr_stack_used); + if (isr_stack_used_bytes > isr_stack_total_bytes) + { + isr_stack_used_bytes = isr_stack_total_bytes; + } + } + + usage.used_bytes = static_bytes + heap_used_bytes + isr_stack_used_bytes; + if (usage.used_bytes > usage.total_bytes) + { + usage.used_bytes = usage.total_bytes; } return usage; } -uint32_t accumulateFsBytes(File dir) -{ - uint32_t total = 0; - if (!dir) - { - return total; - } - - dir.rewindDirectory(); - while (true) - { - File entry = dir.openNextFile(); - if (!entry) - { - break; - } - if (entry.isDirectory()) - { - total += accumulateFsBytes(entry); - } - else - { - total += entry.size(); - } - entry.close(); - } - return total; -} - ui::mono_128x64::HostCallbacks::ResourceUsage flash_usage() { ui::mono_128x64::HostCallbacks::ResourceUsage usage{}; - if (!InternalFS.begin()) + if (!::platform::nrf52::arduino_common::internal_fs::ensureMounted(false)) { return usage; } @@ -104,7 +117,7 @@ ui::mono_128x64::HostCallbacks::ResourceUsage flash_usage() } usage.available = true; - usage.used_bytes = accumulateFsBytes(root); + usage.used_bytes = ::platform::nrf52::arduino_common::internal_fs::accumulateBytes(root); usage.total_bytes = kGat562FsTotalBytes; root.close(); return usage; diff --git a/boards/T-Deck-Pro.json b/boards/T-Deck-Pro.json new file mode 100644 index 00000000..11592bdb --- /dev/null +++ b/boards/T-Deck-Pro.json @@ -0,0 +1,38 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_qspi" + }, + "core": "esp32", + "extra_flags": [ + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "LilyGo T-Deck Pro (16M Flash 8M QSPI PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.lilygo.cc", + "vendor": "LilyGo" +} diff --git a/boards/T-Deck.json b/boards/T-Deck.json index b26f44e7..0e2eda24 100644 --- a/boards/T-Deck.json +++ b/boards/T-Deck.json @@ -16,7 +16,8 @@ "flash_mode": "qio", "hwids": [["0x303A", "0x1001"]], "mcu": "esp32s3", - "variant": "esp32s3" + "variant": "tdeck", + "variants_dir": "variants" }, "connectivity": ["wifi"], "debug": { @@ -25,7 +26,7 @@ "openocd_target": "esp32s3.cfg" }, "frameworks": ["arduino", "espidf"], - "name": "LilyGo T-Decl(16M Flash 8M OPI PSRAM )", + "name": "LilyGo T-Deck (16M Flash 8M OPI PSRAM)", "upload": { "flash_size": "16MB", "maximum_ram_size": 327680, diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h index df5bbe84..133882eb 100644 --- a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h @@ -78,6 +78,12 @@ struct BoardProfile float adc_multiplier = 1.73f; }; + struct BuzzerProfile + { + int pin = -1; + bool active_high = true; + }; + struct ProductBoundary { bool supports_meshtastic = true; @@ -108,6 +114,7 @@ struct BoardProfile LoraPins lora{}; GpsProfile gps{}; BatteryProfile battery{}; + BuzzerProfile buzzer{}; int peripheral_3v3_enable = -1; bool has_screen = true; bool use_ssd1306 = true; @@ -126,6 +133,7 @@ inline constexpr BoardProfile kBoardProfile{ {{43, 45, 44, 42}, 47, 46, 38, 37, true, 1.8f}, {{15, 16, -1}, 17, 9600}, {5, 12, 3.0f, 1.73f}, + {21, true}, 34, true, true, diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h index 8f17509e..70b6f5d7 100644 --- a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h @@ -8,6 +8,7 @@ #include #include +#include class TwoWire; @@ -24,6 +25,9 @@ class IRadioPacketIo; namespace boards::gat562_mesh_evb_pro { +class GpsRuntime; +class InputRuntime; + enum class BoardInputKey : uint8_t { None = 0, @@ -76,6 +80,7 @@ class Gat562Board final : public BoardBase }; static Gat562Board& instance(); + ~Gat562Board() override; uint32_t begin(uint32_t disable_hw_init = 0) override; void wakeUp() override; @@ -150,7 +155,7 @@ class Gat562Board final : public BoardBase uint32_t currentEpochSeconds() const; private: - Gat562Board() = default; + Gat562Board(); void initializeBoardHardware(); void enablePeripheralRail(); @@ -164,6 +169,8 @@ class Gat562Board final : public BoardBase uint8_t brightness_ = DEVICE_MAX_BRIGHTNESS_LEVEL; uint8_t keyboard_brightness_ = 0; uint8_t message_tone_volume_ = 45; + std::unique_ptr gps_runtime_; + std::unique_ptr input_runtime_; }; } // namespace boards::gat562_mesh_evb_pro diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h new file mode 100644 index 00000000..ec0e5f78 --- /dev/null +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gps_runtime.h @@ -0,0 +1,54 @@ +#pragma once + +#include "app/app_config.h" +#include "gps/domain/gnss_satellite.h" +#include "gps/domain/gps_state.h" + +#include +#include + +namespace boards::gat562_mesh_evb_pro +{ + +class GpsRuntime +{ + public: + GpsRuntime(); + ~GpsRuntime(); + + bool start(const app::AppConfig& config); + bool begin(const app::AppConfig& config); + void applyConfig(const app::AppConfig& config); + void tick(); + + bool isReady() const; + ::gps::GpsState data() const; + bool enabled() const; + bool powered() const; + uint32_t lastMotionMs() const; + bool gnssSnapshot(::gps::GnssSatInfo* out, + std::size_t max, + std::size_t* out_count, + ::gps::GnssStatus* status) const; + + void setCollectionInterval(uint32_t interval_ms); + void setPowerStrategy(uint8_t strategy); + void setConfig(uint8_t mode, uint8_t sat_mask); + void setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); + void setMotionIdleTimeout(uint32_t timeout_ms); + void setMotionSensorId(uint8_t sensor_id); + void suspend(); + void resume(); + void setCurrentEpochSeconds(uint32_t epoch_s); + uint32_t currentEpochSeconds() const; + bool isRtcReady() const; + + private: + struct Impl; + Impl* impl(); + const Impl* impl() const; + + Impl* impl_ = nullptr; +}; + +} // namespace boards::gat562_mesh_evb_pro diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/input_runtime.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/input_runtime.h new file mode 100644 index 00000000..b41e475d --- /dev/null +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/input_runtime.h @@ -0,0 +1,47 @@ +#pragma once + +#include "boards/gat562_mesh_evb_pro/gat562_board.h" + +namespace boards::gat562_mesh_evb_pro +{ + +class InputRuntime +{ + public: + bool pollSnapshot(BoardInputSnapshot* out_snapshot) const; + uint16_t debounceMs() const; + bool pollEvent(BoardInputEvent* out_event); + + private: + struct DebounceState + { + bool stable = false; + bool sampled = false; + uint32_t changed_at_ms = 0; + }; + + struct State + { + uint32_t last_activity_ms = 0; + BoardInputSnapshot snapshot{}; + DebounceState button_primary{}; + DebounceState button_secondary{}; + DebounceState joystick_up{}; + DebounceState joystick_down{}; + DebounceState joystick_left{}; + DebounceState joystick_right{}; + DebounceState joystick_press{}; + }; + + static bool updateDebounced(bool sampled, + DebounceState& state, + uint16_t debounce_ms, + BoardInputKey key, + BoardInputEvent* out_event, + uint32_t now_ms, + State& runtime_state); + + mutable State state_{}; +}; + +} // namespace boards::gat562_mesh_evb_pro diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h index 0db34b54..3bf9e7e9 100644 --- a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h @@ -1,6 +1,8 @@ #pragma once #include "app/app_config.h" +#include "meshtastic/config.pb.h" +#include "meshtastic/localonly.pb.h" #include @@ -27,8 +29,16 @@ enum class StoreStatus : uint8_t void normalizeConfig(app::AppConfig& config); bool loadAppConfig(app::AppConfig& config); bool saveAppConfig(const app::AppConfig& config); +void queueSaveAppConfig(const app::AppConfig& config); uint8_t loadMessageToneVolume(); bool saveMessageToneVolume(uint8_t volume); +void queueSaveMessageToneVolume(uint8_t volume); +bool loadMeshtasticBleState(meshtastic_Config_BluetoothConfig* bluetooth, + meshtastic_LocalModuleConfig* module); +bool saveMeshtasticBleState(const meshtastic_Config_BluetoothConfig& bluetooth, + const meshtastic_LocalModuleConfig& module); +bool tickDeferredSave(); +bool hasDeferredSavePending(); StoreStatus lastLoadStatus(); StoreStatus lastSaveStatus(); const char* statusLabel(StoreStatus status); diff --git a/boards/gat562_mesh_evb_pro/library.json b/boards/gat562_mesh_evb_pro/library.json index ba5d768b..2e16cc10 100644 --- a/boards/gat562_mesh_evb_pro/library.json +++ b/boards/gat562_mesh_evb_pro/library.json @@ -27,9 +27,7 @@ "-I../../.pio/libdeps/gat562_mesh_evb_pro/TinyGPSPlus/src", "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ GFX\\ Library", "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ SSD1306", - "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ BusIO", - "-IC:/Users/VicLi/.platformio/packages/framework-arduinoadafruitnrf52/libraries/Adafruit_LittleFS/src", - "-IC:/Users/VicLi/.platformio/packages/framework-arduinoadafruitnrf52/libraries/InternalFileSytem/src" + "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ BusIO" ] } } diff --git a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp index 1c3166ac..fbca8ef4 100644 --- a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp +++ b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp @@ -1,6 +1,8 @@ #include "boards/gat562_mesh_evb_pro/gat562_board.h" #include "boards/gat562_mesh_evb_pro/board_profile.h" +#include "boards/gat562_mesh_evb_pro/gps_runtime.h" +#include "boards/gat562_mesh_evb_pro/input_runtime.h" #include "boards/gat562_mesh_evb_pro/settings_store.h" #include "boards/gat562_mesh_evb_pro/sx1262_radio_packet_io.h" #include "platform/nrf52/arduino_common/chat/infra/radio_packet_io.h" @@ -736,6 +738,14 @@ Gat562Board& Gat562Board::instance() return board_instance; } +Gat562Board::Gat562Board() + : gps_runtime_(new GpsRuntime()), + input_runtime_(new InputRuntime()) +{ +} + +Gat562Board::~Gat562Board() = default; + uint32_t Gat562Board::begin(uint32_t disable_hw_init) { (void)disable_hw_init; @@ -857,7 +867,7 @@ uint8_t Gat562Board::keyboardGetBrightness() bool Gat562Board::isRTCReady() const { - return s_gps.time_synced && currentEpochSeconds() >= kMinValidEpochSeconds; + return gps_runtime_ ? gps_runtime_->isRtcReady() : false; } bool Gat562Board::isCharging() @@ -923,12 +933,51 @@ void Gat562Board::stopVibrator() void Gat562Board::playMessageTone() { pulseNotificationLed(25); + + if (message_tone_volume_ == 0) + { + return; + } + + const int buzzer_pin = kBoardProfile.buzzer.pin; + if (buzzer_pin < 0) + { + return; + } + + pinMode(buzzer_pin, OUTPUT); + digitalWrite(buzzer_pin, kBoardProfile.buzzer.active_high ? LOW : HIGH); + + struct ToneStep + { + unsigned frequency_hz; + uint16_t duration_ms; + uint16_t gap_ms; + }; + + static constexpr ToneStep kMessageTone[] = { + {1760U, 70U, 25U}, + {2093U, 110U, 0U}, + }; + + for (const ToneStep& step : kMessageTone) + { + tone(static_cast(buzzer_pin), step.frequency_hz, step.duration_ms); + delay(step.duration_ms); + noTone(static_cast(buzzer_pin)); + if (step.gap_ms > 0) + { + delay(step.gap_ms); + } + } + + digitalWrite(buzzer_pin, kBoardProfile.buzzer.active_high ? LOW : HIGH); } void Gat562Board::setMessageToneVolume(uint8_t volume_percent) { message_tone_volume_ = volume_percent; - (void)::boards::gat562_mesh_evb_pro::settings_store::saveMessageToneVolume(volume_percent); + ::boards::gat562_mesh_evb_pro::settings_store::queueSaveMessageToneVolume(volume_percent); } uint8_t Gat562Board::getMessageToneVolume() const @@ -958,27 +1007,7 @@ void Gat562Board::pulseNotificationLed(uint32_t pulse_ms) bool Gat562Board::pollInputSnapshot(BoardInputSnapshot* out_snapshot) const { - if (!out_snapshot) - { - return false; - } - - const auto& inputs = kBoardProfile.inputs; - BoardInputSnapshot snapshot{}; - snapshot.button_primary = readActiveLowPin(inputs.button_primary, inputs.buttons_need_pullup); - snapshot.button_secondary = readActiveLowPin(inputs.button_secondary, inputs.buttons_need_pullup); - snapshot.joystick_up = readActiveLowPin(inputs.joystick_up, inputs.joystick_need_pullup); - snapshot.joystick_down = readActiveLowPin(inputs.joystick_down, inputs.joystick_need_pullup); - snapshot.joystick_left = readActiveLowPin(inputs.joystick_left, inputs.joystick_need_pullup); - snapshot.joystick_right = readActiveLowPin(inputs.joystick_right, inputs.joystick_need_pullup); - snapshot.joystick_press = readActiveLowPin(inputs.joystick_press, inputs.joystick_need_pullup); - snapshot.any_activity = snapshot.button_primary || snapshot.button_secondary || - snapshot.joystick_up || snapshot.joystick_down || - snapshot.joystick_left || snapshot.joystick_right || - snapshot.joystick_press; - - *out_snapshot = snapshot; - return snapshot.any_activity; + return input_runtime_ ? input_runtime_->pollSnapshot(out_snapshot) : false; } bool Gat562Board::formatLoraFrequencyMHz(uint32_t freq_hz, char* out, std::size_t out_len) const @@ -998,7 +1027,7 @@ bool Gat562Board::formatLoraFrequencyMHz(uint32_t freq_hz, char* out, std::size_ uint16_t Gat562Board::inputDebounceMs() const { - return kBoardProfile.inputs.debounce_ms; + return input_runtime_ ? input_runtime_->debounceMs() : kBoardProfile.inputs.debounce_ms; } bool Gat562Board::ensureI2cReady() @@ -1053,32 +1082,7 @@ TwoWire& Gat562Board::i2cWire() bool Gat562Board::pollInputEvent(BoardInputEvent* out_event) { - if (out_event) - { - *out_event = BoardInputEvent{}; - } - - BoardInputSnapshot current{}; - (void)pollInputSnapshot(¤t); - s_input.snapshot = current; - - const uint32_t now_ms = millis(); - const uint16_t debounce_ms = inputDebounceMs(); - - return updateDebounced(current.button_primary, s_input.button_primary, debounce_ms, - BoardInputKey::PrimaryButton, out_event, now_ms) || - updateDebounced(current.button_secondary, s_input.button_secondary, debounce_ms, - BoardInputKey::SecondaryButton, out_event, now_ms) || - updateDebounced(current.joystick_up, s_input.joystick_up, debounce_ms, - BoardInputKey::JoystickUp, out_event, now_ms) || - updateDebounced(current.joystick_down, s_input.joystick_down, debounce_ms, - BoardInputKey::JoystickDown, out_event, now_ms) || - updateDebounced(current.joystick_left, s_input.joystick_left, debounce_ms, - BoardInputKey::JoystickLeft, out_event, now_ms) || - updateDebounced(current.joystick_right, s_input.joystick_right, debounce_ms, - BoardInputKey::JoystickRight, out_event, now_ms) || - updateDebounced(current.joystick_press, s_input.joystick_press, debounce_ms, - BoardInputKey::JoystickPress, out_event, now_ms); + return input_runtime_ ? input_runtime_->pollEvent(out_event) : false; } namespace @@ -1270,104 +1274,53 @@ uint32_t Gat562Board::activeLoraFrequencyHz() const bool Gat562Board::startGpsRuntime(const app::AppConfig& config) { - if (!beginGps(config)) - { - return false; - } - applyGpsConfig(config); - return true; + return gps_runtime_ ? gps_runtime_->start(config) : false; } bool Gat562Board::beginGps(const app::AppConfig& config) { - (void)config; - if (!s_gps.initialized) - { - const auto& profile = kBoardProfile; - if (profile.gps.uart.aux >= 0) - { - pinMode(profile.gps.uart.aux, OUTPUT); - digitalWrite(profile.gps.uart.aux, HIGH); - } - Serial1.setPins(profile.gps.uart.rx, profile.gps.uart.tx); - Serial1.begin(profile.gps.baud_rate); - s_gps.initialized = true; - s_gps.powered = true; - } - return true; + return gps_runtime_ ? gps_runtime_->begin(config) : false; } void Gat562Board::applyGpsConfig(const app::AppConfig& config) { - s_gps.collection_interval_ms = config.gps_interval_ms; - s_gps.power_strategy = config.gps_strategy; - s_gps.gnss_mode = config.gps_mode; - s_gps.sat_mask = config.gps_sat_mask; - s_gps.nmea_output_hz = config.privacy_nmea_output; - s_gps.nmea_sentence_mask = config.privacy_nmea_sentence; - s_gps.motion_idle_timeout_ms = config.motion_config.idle_timeout_ms; - s_gps.motion_sensor_id = config.motion_config.sensor_id; - s_gps.enabled = (config.gps_mode != 0); - if (!s_gps.enabled) + if (gps_runtime_) { - clearGpsObservations(); + gps_runtime_->applyConfig(config); } - Serial.printf( - "[gat562][gps] config enabled=%u interval_ms=%lu strategy=%u mode=%u sat_mask=0x%02X nmea_hz=%u nmea_mask=0x%02X motion_idle_ms=%lu motion_sensor=%u\n", - static_cast(s_gps.enabled ? 1 : 0), - static_cast(s_gps.collection_interval_ms), - static_cast(s_gps.power_strategy), - static_cast(s_gps.gnss_mode), - static_cast(s_gps.sat_mask), - static_cast(s_gps.nmea_output_hz), - static_cast(s_gps.nmea_sentence_mask), - static_cast(s_gps.motion_idle_timeout_ms), - static_cast(s_gps.motion_sensor_id)); } void Gat562Board::tickGps() { - if (!s_gps.initialized || !s_gps.enabled) + if (gps_runtime_) { - return; + gps_runtime_->tick(); } - - while (Serial1.available() > 0) - { - s_gps.nmea_seen = true; - s_gps.last_nmea_ms = millis(); - const char ch = static_cast(Serial1.read()); - s_gps.parser.encode(ch); - processGpsNmeaChar(ch); - } - applyGpsTimeIfValid(); - refreshGpsFix(); - logGpsStatusIfDue(); } bool Gat562Board::isGpsRuntimeReady() const { - return s_gps.initialized && s_gps.powered; + return gps_runtime_ ? gps_runtime_->isReady() : false; } ::gps::GpsState Gat562Board::gpsData() const { - return s_gps.data; + return gps_runtime_ ? gps_runtime_->data() : ::gps::GpsState{}; } bool Gat562Board::gpsEnabled() const { - return s_gps.enabled; + return gps_runtime_ ? gps_runtime_->enabled() : false; } bool Gat562Board::gpsPowered() const { - return s_gps.powered; + return gps_runtime_ ? gps_runtime_->powered() : false; } uint32_t Gat562Board::gpsLastMotionMs() const { - return s_gps.last_motion_ms; + return gps_runtime_ ? gps_runtime_->lastMotionMs() : 0; } bool Gat562Board::gpsGnssSnapshot(::gps::GnssSatInfo* out, @@ -1375,96 +1328,77 @@ bool Gat562Board::gpsGnssSnapshot(::gps::GnssSatInfo* out, std::size_t* out_count, ::gps::GnssStatus* status) const { - if (out_count) - { - *out_count = 0; - } - if (status) - { - *status = s_gps.status; - } - if (out && max > 0 && s_gps.sat_count > 0) - { - const std::size_t copy_count = std::min(max, s_gps.sat_count); - for (std::size_t index = 0; index < copy_count; ++index) - { - out[index] = s_gps.sats[index]; - } - if (out_count) - { - *out_count = copy_count; - } - return true; - } - return s_gps.data.valid || s_gps.data.satellites > 0 || s_gps.sat_count > 0; + return gps_runtime_ ? gps_runtime_->gnssSnapshot(out, max, out_count, status) : false; } -void Gat562Board::setGpsCollectionInterval(uint32_t interval_ms) { s_gps.collection_interval_ms = interval_ms; } -void Gat562Board::setGpsPowerStrategy(uint8_t strategy) { s_gps.power_strategy = strategy; } +void Gat562Board::setGpsCollectionInterval(uint32_t interval_ms) +{ + if (gps_runtime_) + { + gps_runtime_->setCollectionInterval(interval_ms); + } +} +void Gat562Board::setGpsPowerStrategy(uint8_t strategy) +{ + if (gps_runtime_) + { + gps_runtime_->setPowerStrategy(strategy); + } +} void Gat562Board::setGpsConfig(uint8_t mode, uint8_t sat_mask) { - s_gps.gnss_mode = mode; - s_gps.sat_mask = sat_mask; - s_gps.enabled = (mode != 0); - if (!s_gps.enabled) + if (gps_runtime_) { - clearGpsObservations(); + gps_runtime_->setConfig(mode, sat_mask); } } void Gat562Board::setGpsNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) { - s_gps.nmea_output_hz = output_hz; - s_gps.nmea_sentence_mask = sentence_mask; + if (gps_runtime_) + { + gps_runtime_->setNmeaConfig(output_hz, sentence_mask); + } +} +void Gat562Board::setGpsMotionIdleTimeout(uint32_t timeout_ms) +{ + if (gps_runtime_) + { + gps_runtime_->setMotionIdleTimeout(timeout_ms); + } +} +void Gat562Board::setGpsMotionSensorId(uint8_t sensor_id) +{ + if (gps_runtime_) + { + gps_runtime_->setMotionSensorId(sensor_id); + } } -void Gat562Board::setGpsMotionIdleTimeout(uint32_t timeout_ms) { s_gps.motion_idle_timeout_ms = timeout_ms; } -void Gat562Board::setGpsMotionSensorId(uint8_t sensor_id) { s_gps.motion_sensor_id = sensor_id; } void Gat562Board::suspendGps() { - s_gps.enabled = false; - clearGpsObservations(); + if (gps_runtime_) + { + gps_runtime_->suspend(); + } } void Gat562Board::resumeGps() { - s_gps.enabled = (s_gps.gnss_mode != 0); - if (!s_gps.enabled) + if (gps_runtime_) { - clearGpsObservations(); + gps_runtime_->resume(); } } void Gat562Board::setCurrentEpochSeconds(uint32_t epoch_s) { - if (epoch_s < kMinValidEpochSeconds) + if (gps_runtime_) { - return; + gps_runtime_->setCurrentEpochSeconds(epoch_s); } - - const uint32_t prev_epoch_s = s_gps.epoch_base_s; - s_gps.epoch_base_s = epoch_s; - s_gps.epoch_base_ms = millis(); - s_gps.time_synced = true; - s_gps.last_time_sync_epoch_logged = epoch_s; - s_gps.last_time_sync_log_ms = s_gps.epoch_base_ms; - Serial.printf("[gat562][gps] time sync source=external epoch=%lu prev=%lu\n", - static_cast(epoch_s), - static_cast(prev_epoch_s)); - syncSystemClockFromEpoch(epoch_s); } uint32_t Gat562Board::currentEpochSeconds() const { - const uint32_t system_epoch_s = readSystemEpochSeconds(); - if (system_epoch_s >= kMinValidEpochSeconds) - { - return system_epoch_s; - } - - if (!s_gps.time_synced || s_gps.epoch_base_s == 0) - { - return 0; - } - const uint32_t elapsed_s = (millis() - s_gps.epoch_base_ms) / 1000U; - return s_gps.epoch_base_s + elapsed_s; + return gps_runtime_ ? gps_runtime_->currentEpochSeconds() : 0; } } // namespace boards::gat562_mesh_evb_pro diff --git a/boards/gat562_mesh_evb_pro/src/gps_runtime.cpp b/boards/gat562_mesh_evb_pro/src/gps_runtime.cpp new file mode 100644 index 00000000..912efa3e --- /dev/null +++ b/boards/gat562_mesh_evb_pro/src/gps_runtime.cpp @@ -0,0 +1,1172 @@ +#include "boards/gat562_mesh_evb_pro/gps_runtime.h" + +#include "boards/gat562_mesh_evb_pro/board_profile.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace boards::gat562_mesh_evb_pro +{ +namespace +{ + +constexpr uint32_t kMinValidEpochSeconds = 1700000000UL; +constexpr std::size_t kNmeaFieldMax = 24; +constexpr std::size_t kGpsTailCanarySize = 32; +constexpr uint8_t kGpsTailCanaryByte = 0xA5; + +uint32_t readSystemEpochSeconds() +{ + const time_t now = ::time(nullptr); + if (now < static_cast(kMinValidEpochSeconds)) + { + return 0; + } + return static_cast(now); +} + +void syncSystemClockFromEpoch(uint32_t epoch_s) +{ + (void)epoch_s; +} + +void fillCanary(uint8_t* bytes, std::size_t len) +{ + if (!bytes || len == 0) + { + return; + } + std::memset(bytes, static_cast(kGpsTailCanaryByte), len); +} + +bool canaryIntact(const uint8_t* bytes, std::size_t len) +{ + if (!bytes) + { + return false; + } + for (std::size_t i = 0; i < len; ++i) + { + if (bytes[i] != kGpsTailCanaryByte) + { + return false; + } + } + return true; +} + +int firstBadCanaryOffset(const uint8_t* bytes, std::size_t len) +{ + if (!bytes) + { + return -1; + } + for (std::size_t i = 0; i < len; ++i) + { + if (bytes[i] != kGpsTailCanaryByte) + { + return static_cast(i); + } + } + return -1; +} + +void dumpCanaryHex(const uint8_t* bytes, std::size_t len, char* out, std::size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (!bytes || len == 0) + { + return; + } + + static constexpr char kHex[] = "0123456789ABCDEF"; + std::size_t pos = 0; + for (std::size_t i = 0; i < len && (pos + 3) < out_len; ++i) + { + const uint8_t b = bytes[i]; + out[pos++] = kHex[(b >> 4) & 0x0F]; + out[pos++] = kHex[b & 0x0F]; + if (i + 1 < len) + { + out[pos++] = ' '; + } + } + out[pos] = '\0'; +} + +enum class CollectorSlot : uint8_t +{ + GPS = 0, + GLN = 1, + GAL = 2, + BD = 3, + UNKNOWN = 4, +}; + +CollectorSlot collectorSlotForTalker(const char* talker) +{ + if (!talker || talker[0] == '\0' || talker[1] == '\0') + { + return CollectorSlot::UNKNOWN; + } + if (talker[0] == 'G' && talker[1] == 'P') + { + return CollectorSlot::GPS; + } + if (talker[0] == 'G' && talker[1] == 'L') + { + return CollectorSlot::GLN; + } + if (talker[0] == 'G' && talker[1] == 'A') + { + return CollectorSlot::GAL; + } + if ((talker[0] == 'G' && talker[1] == 'B') || (talker[0] == 'B' && talker[1] == 'D')) + { + return CollectorSlot::BD; + } + return CollectorSlot::UNKNOWN; +} + +::gps::GnssSystem systemForSlot(CollectorSlot slot) +{ + switch (slot) + { + case CollectorSlot::GPS: + return ::gps::GnssSystem::GPS; + case CollectorSlot::GLN: + return ::gps::GnssSystem::GLN; + case CollectorSlot::GAL: + return ::gps::GnssSystem::GAL; + case CollectorSlot::BD: + return ::gps::GnssSystem::BD; + default: + return ::gps::GnssSystem::UNKNOWN; + } +} + +bool parseUint(const char* text, uint32_t* out) +{ + if (!text || !*text || !out) + { + return false; + } + char* end = nullptr; + unsigned long value = std::strtoul(text, &end, 10); + if (end == text) + { + return false; + } + *out = static_cast(value); + return true; +} + +bool parseInt(const char* text, int* out) +{ + if (!text || !*text || !out) + { + return false; + } + char* end = nullptr; + long value = std::strtol(text, &end, 10); + if (end == text) + { + return false; + } + *out = static_cast(value); + return true; +} + +bool verifyChecksum(const char* line) +{ + if (!line || line[0] != '$') + { + return false; + } + + const char* star = std::strchr(line, '*'); + if (!star || !star[1] || !star[2]) + { + return true; + } + + uint8_t checksum = 0; + for (const char* cursor = line + 1; cursor < star; ++cursor) + { + checksum ^= static_cast(*cursor); + } + + char checksum_text[3] = {star[1], star[2], '\0'}; + char* end = nullptr; + long expected = std::strtol(checksum_text, &end, 16); + return end != checksum_text && checksum == static_cast(expected & 0xFF); +} + +std::size_t splitFields(char* sentence, std::array& fields) +{ + fields.fill(nullptr); + std::size_t count = 0; + char* cursor = sentence; + while (cursor && *cursor && count < fields.size()) + { + fields[count++] = cursor; + char* comma = std::strchr(cursor, ','); + if (!comma) + { + break; + } + *comma = '\0'; + cursor = comma + 1; + } + return count; +} + +uint8_t daysInMonth(int year, uint8_t month) +{ + static constexpr uint8_t kDays[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month == 0 || month > 12) + { + return 31; + } + if (month != 2) + { + return kDays[month - 1]; + } + const bool leap = ((year % 4) == 0 && (year % 100) != 0) || ((year % 400) == 0); + return leap ? 29 : 28; +} + +bool gpsDateTimeValid(int year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second) +{ + if (year < 2020 || year > 2100) + { + return false; + } + if (month < 1 || month > 12) + { + return false; + } + const uint8_t max_day = daysInMonth(year, month); + if (day < 1 || day > max_day) + { + return false; + } + if (hour >= 24 || minute >= 60 || second >= 60) + { + return false; + } + return true; +} + +int64_t daysFromCivil(int year, unsigned month, unsigned day) +{ + year -= month <= 2 ? 1 : 0; + const int era = (year >= 0 ? year : year - 399) / 400; + const unsigned yoe = static_cast(year - era * 400); + const unsigned doy = (153 * (month + (month > 2 ? static_cast(-3) : 9)) + 2) / 5 + day - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return static_cast(era) * 146097 + static_cast(doe) - 719468; +} + +time_t gpsDateTimeToEpochUtc(int year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second) +{ + const int64_t days = daysFromCivil(year, month, day); + const int64_t sec_of_day = + static_cast(hour) * 3600 + static_cast(minute) * 60 + static_cast(second); + const int64_t epoch64 = days * 86400 + sec_of_day; + if (epoch64 < 0 || epoch64 > static_cast(std::numeric_limits::max())) + { + return static_cast(-1); + } + return static_cast(epoch64); +} + +} // namespace + +struct GpsRuntime::Impl +{ + static constexpr uint32_t kGuardValue = 0x47505352UL; + + struct GsvCollector + { + std::array<::gps::GnssSatInfo, ::gps::kMaxGnssSats> sats{}; + std::size_t count = 0; + }; + + uint32_t guard_begin = kGuardValue; + TinyGPSPlus parser{}; + ::gps::GpsState data{}; + ::gps::GnssStatus status{}; + uint32_t last_motion_ms = 0; + uint32_t collection_interval_ms = 60000; + uint32_t motion_idle_timeout_ms = 0; + uint8_t power_strategy = 0; + uint8_t gnss_mode = 0; + uint8_t sat_mask = 0; + uint8_t nmea_output_hz = 0; + uint8_t nmea_sentence_mask = 0; + uint8_t motion_sensor_id = 0; + uint32_t epoch_base_s = 0; + uint32_t epoch_base_ms = 0; + uint32_t last_nmea_ms = 0; + uint32_t last_time_sync_log_ms = 0; + uint32_t last_time_sync_epoch_logged = 0; + uint32_t last_status_log_ms = 0; + std::array gsv{}; + std::array<::gps::GnssSatInfo, ::gps::kMaxGnssSats> sats{}; + std::array used_sat_ids{}; + char nmea_line[96] = {}; + std::size_t sat_count = 0; + std::size_t used_sat_count = 0; + std::size_t nmea_line_len = 0; + bool enabled = true; + bool powered = false; + bool initialized = false; + bool time_synced = false; + bool nmea_seen = false; + uint32_t last_sat_diag_log_ms = 0; + uint8_t last_logged_parser_sats = 0xFF; + uint8_t last_logged_status_in_view = 0xFF; + uint8_t last_logged_status_in_use = 0xFF; + std::size_t last_logged_snapshot_count = static_cast(-1); + bool last_logged_snapshot_available = false; + bool first_nmea_sentence_logged = false; + std::array tail_canary{}; + uint32_t guard_end = kGuardValue; + + void initializeDebugGuards() + { + guard_begin = kGuardValue; + guard_end = kGuardValue; + fillCanary(tail_canary.data(), tail_canary.size()); + } + + void logMemoryLayout(const char* reason) const + { + Serial.printf( + "[gat562][gps][mem] reason=%s impl=%p size=%u guard_begin@%p guard_end@%p canary@%p canary_len=%u parser@%p data@%p status@%p sats@%p used_ids@%p nmea@%p\n", + reason ? reason : "unknown", + static_cast(this), + static_cast(sizeof(*this)), + static_cast(&guard_begin), + static_cast(&guard_end), + static_cast(tail_canary.data()), + static_cast(tail_canary.size()), + static_cast(&parser), + static_cast(&data), + static_cast(&status), + static_cast(sats.data()), + static_cast(used_sat_ids.data()), + static_cast(nmea_line)); + } + + bool usedSat(uint16_t sat_id) const + { + for (std::size_t i = 0; i < used_sat_count; ++i) + { + if (used_sat_ids[i] == sat_id) + { + return true; + } + } + return false; + } + + void mergeGnssSatellites() + { + sat_count = 0; + for (std::size_t collector_index = 0; collector_index < gsv.size(); ++collector_index) + { + auto& collector = gsv[collector_index]; + for (std::size_t sat_index = 0; sat_index < collector.count && sat_count < sats.size(); ++sat_index) + { + auto sat = collector.sats[sat_index]; + sat.used = usedSat(sat.id); + sats[sat_count++] = sat; + } + } + + status.sats_in_view = static_cast(std::min(sat_count, 255U)); + if (used_sat_count > 0) + { + status.sats_in_use = static_cast(std::min(used_sat_count, 255U)); + } + } + + void clearObservations() + { + parser = TinyGPSPlus{}; + data = ::gps::GpsState{}; + status = ::gps::GnssStatus{}; + sats.fill(::gps::GnssSatInfo{}); + used_sat_ids.fill(0); + gsv.fill(GsvCollector{}); + sat_count = 0; + used_sat_count = 0; + last_nmea_ms = 0; + nmea_line_len = 0; + nmea_line[0] = '\0'; + nmea_seen = false; + } + + bool satelliteStateLooksCorrupt() const + { + if (guard_begin != kGuardValue || guard_end != kGuardValue) + { + return true; + } + if (!canaryIntact(tail_canary.data(), tail_canary.size())) + { + return true; + } + if (sat_count > sats.size()) + { + return true; + } + if (used_sat_count > used_sat_ids.size()) + { + return true; + } + if (status.sats_in_view > ::gps::kMaxGnssSats) + { + return true; + } + if (status.sats_in_use > ::gps::kMaxGnssSats) + { + return true; + } + if (!parser.satellites.isValid() && data.satellites == 0 && sat_count == ::gps::kMaxGnssSats) + { + return true; + } + return false; + } + + void repairCorruptSatelliteState(const char* reason) + { + const int bad_offset = firstBadCanaryOffset(tail_canary.data(), tail_canary.size()); + const uint8_t bad_value = + (bad_offset >= 0) ? tail_canary[static_cast(bad_offset)] : 0; + + char canary_hex[3 * kGpsTailCanarySize + 1] = {}; + dumpCanaryHex(tail_canary.data(), tail_canary.size(), canary_hex, sizeof(canary_hex)); + + Serial.printf( + "[gat562][gps][corrupt] reason=%s impl=%p parser_sats=%u snapshot_count=%u used_count=%u status_view=%u status_use=%u guards=%08lX/%08lX canary_bad=%d bad_val=%02X canary=[%s]\n", + reason ? reason : "unknown", + static_cast(this), + static_cast(parser.satellites.isValid() ? parser.satellites.value() : 0U), + static_cast(sat_count), + static_cast(used_sat_count), + static_cast(status.sats_in_view), + static_cast(status.sats_in_use), + static_cast(guard_begin), + static_cast(guard_end), + bad_offset, + static_cast(bad_value), + canary_hex); + + logMemoryLayout("corrupt"); + + guard_begin = kGuardValue; + guard_end = kGuardValue; + fillCanary(tail_canary.data(), tail_canary.size()); + + sats.fill(::gps::GnssSatInfo{}); + used_sat_ids.fill(0); + gsv.fill(GsvCollector{}); + sat_count = 0; + used_sat_count = 0; + status.sats_in_view = 0; + status.sats_in_use = 0; + data.satellites = parser.satellites.isValid() + ? static_cast(std::min(parser.satellites.value(), 255U)) + : 0U; + } + + void parseGsaSentence(const std::array& fields, std::size_t count) + { + if (count < 4) + { + return; + } + + used_sat_count = 0; + for (std::size_t i = 3; i <= 14 && i < count; ++i) + { + uint32_t sat_id = 0; + if (!parseUint(fields[i], &sat_id) || sat_id == 0 || used_sat_count >= used_sat_ids.size()) + { + continue; + } + used_sat_ids[used_sat_count++] = static_cast(sat_id); + } + mergeGnssSatellites(); + if (satelliteStateLooksCorrupt()) + { + repairCorruptSatelliteState("parse_gsa"); + } + } + + void parseGsvSentence(const char* talker, const std::array& fields, std::size_t count) + { + if (count < 4) + { + return; + } + + const CollectorSlot slot = collectorSlotForTalker(talker); + auto& collector = gsv[static_cast(slot)]; + + uint32_t msg_num = 0; + if (!parseUint(fields[2], &msg_num)) + { + return; + } + + uint32_t sats_in_view = 0; + if (parseUint(fields[3], &sats_in_view)) + { + status.sats_in_view = static_cast(std::min(sats_in_view, 255U)); + } + + if (msg_num == 1) + { + collector.count = 0; + } + + for (std::size_t base = 4; base + 3 < count && collector.count < collector.sats.size(); base += 4) + { + uint32_t sat_id = 0; + if (!parseUint(fields[base], &sat_id) || sat_id == 0) + { + continue; + } + + ::gps::GnssSatInfo sat{}; + sat.id = static_cast(sat_id); + sat.sys = systemForSlot(slot); + + uint32_t elevation = 0; + if (parseUint(fields[base + 1], &elevation)) + { + sat.elevation = static_cast(std::min(elevation, 90U)); + } + + uint32_t azimuth = 0; + if (parseUint(fields[base + 2], &azimuth)) + { + sat.azimuth = static_cast(std::min(azimuth, 359U)); + } + + int snr = -1; + if (parseInt(fields[base + 3], &snr)) + { + sat.snr = static_cast(std::clamp(snr, -1, 99)); + } + + collector.sats[collector.count++] = sat; + } + + mergeGnssSatellites(); + if (satelliteStateLooksCorrupt()) + { + repairCorruptSatelliteState("parse_gsv"); + } + } + + void parseNmeaSentence(char* sentence) + { + if (!sentence || sentence[0] != '$' || !verifyChecksum(sentence)) + { + return; + } + + char* payload = sentence + 1; + char* star = std::strchr(payload, '*'); + if (star) + { + *star = '\0'; + } + + std::array fields{}; + const std::size_t count = splitFields(payload, fields); + if (count == 0 || !fields[0] || std::strlen(fields[0]) < 5) + { + return; + } + + char talker[3] = {fields[0][0], fields[0][1], '\0'}; + const char* type = fields[0] + std::strlen(fields[0]) - 3; + + if (!first_nmea_sentence_logged) + { + first_nmea_sentence_logged = true; + Serial.printf("[gat562][gps][flow] first_nmea talker=%s type=%s raw_sats=%u\n", + talker, + type, + static_cast(parser.satellites.isValid() ? parser.satellites.value() : 0U)); + } + + if (std::strcmp(type, "GSA") == 0) + { + parseGsaSentence(fields, count); + } + else if (std::strcmp(type, "GSV") == 0) + { + parseGsvSentence(talker, fields, count); + } + } + + void processNmeaChar(char ch) + { + if (ch == '$') + { + nmea_line_len = 0; + nmea_line[nmea_line_len++] = ch; + return; + } + + if (nmea_line_len == 0) + { + return; + } + + if (ch == '\r' || ch == '\n') + { + if (nmea_line_len > 0) + { + nmea_line[nmea_line_len] = '\0'; + parseNmeaSentence(nmea_line); + nmea_line_len = 0; + } + return; + } + + if (nmea_line_len + 1 < sizeof(nmea_line)) + { + nmea_line[nmea_line_len++] = ch; + } + else + { + nmea_line_len = 0; + } + } + + void applyTimeIfValid() + { + if (!parser.time.isValid() || !parser.date.isValid()) + { + return; + } + + const uint16_t year = parser.date.year(); + const uint8_t month = parser.date.month(); + const uint8_t day = parser.date.day(); + const uint8_t hour = parser.time.hour(); + const uint8_t minute = parser.time.minute(); + const uint8_t second = parser.time.second(); + + if (!gpsDateTimeValid(year, month, day, hour, minute, second)) + { + return; + } + + const time_t utc = gpsDateTimeToEpochUtc(year, month, day, hour, minute, second); + if (utc < static_cast(kMinValidEpochSeconds)) + { + return; + } + + const uint32_t utc_s = static_cast(utc); + if (epoch_base_s == utc_s) + { + return; + } + const uint32_t prev_epoch_s = epoch_base_s; + epoch_base_s = utc_s; + epoch_base_ms = millis(); + time_synced = true; + if (last_time_sync_epoch_logged != utc_s || (epoch_base_ms - last_time_sync_log_ms) >= 1000U) + { + last_time_sync_epoch_logged = utc_s; + last_time_sync_log_ms = epoch_base_ms; + Serial.printf( + "[gat562][gps] time sync source=gnss epoch=%lu prev=%lu sats=%u fix=%u age_ms=%lu date=%04u-%02u-%02u time=%02u:%02u:%02u\n", + static_cast(utc_s), + static_cast(prev_epoch_s), + static_cast(parser.satellites.isValid() ? parser.satellites.value() : 0U), + static_cast(parser.location.isValid() ? 1U : 0U), + static_cast(parser.location.isValid() ? parser.location.age() : 0U), + static_cast(year), + static_cast(month), + static_cast(day), + static_cast(hour), + static_cast(minute), + static_cast(second)); + } + syncSystemClockFromEpoch(utc_s); + } + + void logStatusIfDue() + { + if (!initialized || !enabled) + { + return; + } + + const uint32_t now_ms = millis(); + const uint32_t interval_ms = collection_interval_ms > 0 ? collection_interval_ms : 60000U; + if ((now_ms - last_status_log_ms) < interval_ms) + { + return; + } + last_status_log_ms = now_ms; + + const bool time_valid = parser.time.isValid(); + const bool date_valid = parser.date.isValid(); + const bool fix_valid = parser.location.isValid(); + const uint16_t year = date_valid ? parser.date.year() : 0U; + const uint8_t month = date_valid ? parser.date.month() : 0U; + const uint8_t day = date_valid ? parser.date.day() : 0U; + const uint8_t hour = time_valid ? parser.time.hour() : 0U; + const uint8_t minute = time_valid ? parser.time.minute() : 0U; + const uint8_t second = time_valid ? parser.time.second() : 0U; + const bool datetime_shape_valid = + time_valid && date_valid && gpsDateTimeValid(year, month, day, hour, minute, second); + const time_t utc = datetime_shape_valid ? gpsDateTimeToEpochUtc(year, month, day, hour, minute, second) + : static_cast(0); + const bool epoch_ok = utc >= static_cast(kMinValidEpochSeconds); + const uint32_t sat_count_parser = parser.satellites.isValid() ? parser.satellites.value() : 0U; + const uint32_t nmea_age_ms = last_nmea_ms > 0 ? (now_ms - last_nmea_ms) : 0U; + const char* state = "idle"; + if (!nmea_seen) + { + state = "no_nmea"; + } + else if (!time_valid || !date_valid) + { + state = "time_invalid"; + } + else if (!datetime_shape_valid) + { + state = "datetime_reject"; + } + else if (!epoch_ok) + { + state = "epoch_reject"; + } + else if (sat_count_parser == 0U) + { + state = "time_only"; + } + else if (!fix_valid) + { + state = "search_fix"; + } + else if (!time_synced) + { + state = "ready_unsynced"; + } + else + { + state = "synced"; + } + + Serial.printf( + "[gat562][gps] status state=%s enabled=%u powered=%u nmea=%u nmea_age_ms=%lu time=%u date=%u fix=%u sats=%u lat=%.6f lng=%.6f epoch=%lu utc=%lu dt=%04u-%02u-%02uT%02u:%02u:%02u\n", + state, + static_cast(enabled ? 1 : 0), + static_cast(powered ? 1 : 0), + static_cast(nmea_seen ? 1 : 0), + static_cast(nmea_age_ms), + static_cast(time_valid ? 1 : 0), + static_cast(date_valid ? 1 : 0), + static_cast(fix_valid ? 1 : 0), + static_cast(sat_count_parser), + fix_valid ? parser.location.lat() : 0.0, + fix_valid ? parser.location.lng() : 0.0, + static_cast(epoch_base_s), + static_cast(epoch_ok ? static_cast(utc) : 0U), + static_cast(year), + static_cast(month), + static_cast(day), + static_cast(hour), + static_cast(minute), + static_cast(second)); + } + + void refreshFix() + { + data.valid = parser.location.isValid(); + data.lat = parser.location.lat(); + data.lng = parser.location.lng(); + data.has_alt = parser.altitude.isValid(); + data.alt_m = data.has_alt ? parser.altitude.meters() : 0.0; + data.has_speed = parser.speed.isValid(); + data.speed_mps = data.has_speed ? (parser.speed.kmph() / 3.6) : 0.0; + data.has_course = parser.course.isValid(); + data.course_deg = data.has_course ? parser.course.deg() : 0.0; + data.satellites = static_cast( + std::min(parser.satellites.isValid() ? parser.satellites.value() : 0U, 255U)); + data.age = parser.location.isValid() ? static_cast(parser.location.age()) : 0xFFFFFFFFUL; + + status.sats_in_use = used_sat_count > 0 + ? static_cast(std::min(used_sat_count, 255U)) + : data.satellites; + status.sats_in_view = sat_count > 0 + ? static_cast(std::min(sat_count, 255U)) + : data.satellites; + + if (sat_count == 0 && data.satellites == 0) + { + status.sats_in_use = 0; + status.sats_in_view = 0; + } + + status.hdop = parser.hdop.isValid() ? static_cast(parser.hdop.hdop()) : 0.0f; + status.fix = data.valid ? (data.has_alt ? ::gps::GnssFix::FIX3D : ::gps::GnssFix::FIX2D) + : ::gps::GnssFix::NOFIX; + + if (data.valid) + { + last_motion_ms = millis(); + } + + if (satelliteStateLooksCorrupt()) + { + repairCorruptSatelliteState("refresh_fix"); + } + } + + void logSatelliteFlowIfChanged() + { + const uint32_t now_ms = millis(); + const uint8_t parser_sats = + static_cast(std::min(parser.satellites.isValid() ? parser.satellites.value() : 0U, 255U)); + const uint8_t status_in_view = status.sats_in_view; + const uint8_t status_in_use = status.sats_in_use; + const std::size_t snapshot_count = sat_count; + const bool snapshot_available = data.valid || data.satellites > 0 || sat_count > 0; + + const bool changed = (parser_sats != last_logged_parser_sats) || (status_in_view != last_logged_status_in_view) || + (status_in_use != last_logged_status_in_use) || + (snapshot_count != last_logged_snapshot_count) || + (snapshot_available != last_logged_snapshot_available); + if (!changed && (now_ms - last_sat_diag_log_ms) < 3000U) + { + return; + } + + last_sat_diag_log_ms = now_ms; + last_logged_parser_sats = parser_sats; + last_logged_status_in_view = status_in_view; + last_logged_status_in_use = status_in_use; + last_logged_snapshot_count = snapshot_count; + last_logged_snapshot_available = snapshot_available; + + Serial.printf( + "[gat562][gps][flow] parser_sats=%u snapshot_count=%u status_view=%u status_use=%u data_valid=%u nmea=%u fix=%u\n", + static_cast(parser_sats), + static_cast(snapshot_count), + static_cast(status_in_view), + static_cast(status_in_use), + static_cast(data.valid ? 1 : 0), + static_cast(nmea_seen ? 1 : 0), + static_cast(status.fix != ::gps::GnssFix::NOFIX ? 1 : 0)); + } +}; + +GpsRuntime::GpsRuntime() + : impl_(new Impl()) +{ + if (impl_) + { + impl_->initializeDebugGuards(); + impl_->logMemoryLayout("ctor"); + } +} + +GpsRuntime::~GpsRuntime() +{ + delete impl_; + impl_ = nullptr; +} + +GpsRuntime::Impl* GpsRuntime::impl() +{ + return impl_; +} + +const GpsRuntime::Impl* GpsRuntime::impl() const +{ + return impl_; +} + +bool GpsRuntime::start(const app::AppConfig& config) +{ + if (!begin(config)) + { + return false; + } + applyConfig(config); + return true; +} + +bool GpsRuntime::begin(const app::AppConfig& config) +{ + (void)config; + auto& s = *impl(); + if (!s.initialized) + { + const auto& profile = kBoardProfile; + if (profile.gps.uart.aux >= 0) + { + pinMode(profile.gps.uart.aux, OUTPUT); + digitalWrite(profile.gps.uart.aux, HIGH); + } + Serial1.setPins(profile.gps.uart.rx, profile.gps.uart.tx); + Serial1.begin(profile.gps.baud_rate); + s.initialized = true; + s.powered = true; + s.logMemoryLayout("begin"); + } + return true; +} + +void GpsRuntime::applyConfig(const app::AppConfig& config) +{ + auto& s = *impl(); + s.collection_interval_ms = config.gps_interval_ms; + s.power_strategy = config.gps_strategy; + s.gnss_mode = config.gps_mode; + s.sat_mask = config.gps_sat_mask; + s.nmea_output_hz = config.privacy_nmea_output; + s.nmea_sentence_mask = config.privacy_nmea_sentence; + s.motion_idle_timeout_ms = config.motion_config.idle_timeout_ms; + s.motion_sensor_id = config.motion_config.sensor_id; + s.enabled = (config.gps_mode != 0); + if (!s.enabled) + { + s.clearObservations(); + } + Serial.printf( + "[gat562][gps] config enabled=%u interval_ms=%lu strategy=%u mode=%u sat_mask=0x%02X nmea_hz=%u nmea_mask=0x%02X motion_idle_ms=%lu motion_sensor=%u\n", + static_cast(s.enabled ? 1 : 0), + static_cast(s.collection_interval_ms), + static_cast(s.power_strategy), + static_cast(s.gnss_mode), + static_cast(s.sat_mask), + static_cast(s.nmea_output_hz), + static_cast(s.nmea_sentence_mask), + static_cast(s.motion_idle_timeout_ms), + static_cast(s.motion_sensor_id)); +} + +void GpsRuntime::tick() +{ + auto& s = *impl(); + if (!s.initialized || !s.enabled) + { + return; + } + + if (s.satelliteStateLooksCorrupt()) + { + s.repairCorruptSatelliteState("tick_pre"); + } + + while (Serial1.available() > 0) + { + s.nmea_seen = true; + s.last_nmea_ms = millis(); + const char ch = static_cast(Serial1.read()); + s.parser.encode(ch); + s.processNmeaChar(ch); + } + + s.applyTimeIfValid(); + s.refreshFix(); + + if (s.satelliteStateLooksCorrupt()) + { + s.repairCorruptSatelliteState("tick_post_refresh"); + } + + s.logSatelliteFlowIfChanged(); + s.logStatusIfDue(); +} + +bool GpsRuntime::isReady() const +{ + const auto& s = *impl(); + return s.initialized && s.powered; +} + +::gps::GpsState GpsRuntime::data() const +{ + return impl()->data; +} + +bool GpsRuntime::enabled() const +{ + return impl()->enabled; +} + +bool GpsRuntime::powered() const +{ + return impl()->powered; +} + +uint32_t GpsRuntime::lastMotionMs() const +{ + return impl()->last_motion_ms; +} + +bool GpsRuntime::gnssSnapshot(::gps::GnssSatInfo* out, + std::size_t max, + std::size_t* out_count, + ::gps::GnssStatus* status) const +{ + auto& s = *const_cast(impl()); + + if (s.satelliteStateLooksCorrupt()) + { + s.logMemoryLayout("snapshot_pre_corrupt"); + s.repairCorruptSatelliteState("snapshot"); + } + + const bool snapshot_available = s.data.valid || s.data.satellites > 0 || s.sat_count > 0; + if (out_count) + { + *out_count = 0; + } + if (status) + { + *status = s.status; + } + if (out && max > 0 && s.sat_count > 0) + { + const std::size_t copy_count = std::min(max, s.sat_count); + for (std::size_t index = 0; index < copy_count; ++index) + { + out[index] = s.sats[index]; + } + if (out_count) + { + *out_count = copy_count; + } + return true; + } + if (snapshot_available) + { + Serial.printf( + "[gat562][gps][snapshot] available_without_copy max=%u raw_count=%u status_view=%u status_use=%u parser_sats=%u data_valid=%u\n", + static_cast(max), + static_cast(s.sat_count), + static_cast(s.status.sats_in_view), + static_cast(s.status.sats_in_use), + static_cast(s.data.satellites), + static_cast(s.data.valid ? 1 : 0)); + } + return snapshot_available; +} + +void GpsRuntime::setCollectionInterval(uint32_t interval_ms) { impl()->collection_interval_ms = interval_ms; } +void GpsRuntime::setPowerStrategy(uint8_t strategy) { impl()->power_strategy = strategy; } + +void GpsRuntime::setConfig(uint8_t mode, uint8_t sat_mask) +{ + auto& s = *impl(); + s.gnss_mode = mode; + s.sat_mask = sat_mask; + s.enabled = (mode != 0); + if (!s.enabled) + { + s.clearObservations(); + } +} + +void GpsRuntime::setNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) +{ + impl()->nmea_output_hz = output_hz; + impl()->nmea_sentence_mask = sentence_mask; +} + +void GpsRuntime::setMotionIdleTimeout(uint32_t timeout_ms) { impl()->motion_idle_timeout_ms = timeout_ms; } +void GpsRuntime::setMotionSensorId(uint8_t sensor_id) { impl()->motion_sensor_id = sensor_id; } + +void GpsRuntime::suspend() +{ + auto& s = *impl(); + s.enabled = false; + s.clearObservations(); +} + +void GpsRuntime::resume() +{ + auto& s = *impl(); + s.enabled = (s.gnss_mode != 0); + if (!s.enabled) + { + s.clearObservations(); + } +} + +void GpsRuntime::setCurrentEpochSeconds(uint32_t epoch_s) +{ + if (epoch_s < kMinValidEpochSeconds) + { + return; + } + + auto& s = *impl(); + const uint32_t prev_epoch_s = s.epoch_base_s; + s.epoch_base_s = epoch_s; + s.epoch_base_ms = millis(); + s.time_synced = true; + s.last_time_sync_epoch_logged = epoch_s; + s.last_time_sync_log_ms = s.epoch_base_ms; + Serial.printf("[gat562][gps] time sync source=external epoch=%lu prev=%lu\n", + static_cast(epoch_s), + static_cast(prev_epoch_s)); + syncSystemClockFromEpoch(epoch_s); +} + +uint32_t GpsRuntime::currentEpochSeconds() const +{ + const uint32_t system_epoch_s = readSystemEpochSeconds(); + if (system_epoch_s >= kMinValidEpochSeconds) + { + return system_epoch_s; + } + + const auto& s = *impl(); + if (!s.time_synced || s.epoch_base_s == 0) + { + return 0; + } + const uint32_t elapsed_s = (millis() - s.epoch_base_ms) / 1000U; + return s.epoch_base_s + elapsed_s; +} + +bool GpsRuntime::isRtcReady() const +{ + const auto& s = *impl(); + return s.time_synced && currentEpochSeconds() >= kMinValidEpochSeconds; +} + +} // namespace boards::gat562_mesh_evb_pro \ No newline at end of file diff --git a/boards/gat562_mesh_evb_pro/src/input_runtime.cpp b/boards/gat562_mesh_evb_pro/src/input_runtime.cpp new file mode 100644 index 00000000..a8735177 --- /dev/null +++ b/boards/gat562_mesh_evb_pro/src/input_runtime.cpp @@ -0,0 +1,122 @@ +#include "boards/gat562_mesh_evb_pro/input_runtime.h" + +#include "boards/gat562_mesh_evb_pro/board_profile.h" + +#include + +namespace boards::gat562_mesh_evb_pro +{ +namespace +{ + +bool readActiveLowPin(int pin, bool use_pullup) +{ + if (pin < 0) + { + return false; + } + pinMode(pin, use_pullup ? INPUT_PULLUP : INPUT); + return digitalRead(pin) == LOW; +} + +} // namespace + +bool InputRuntime::pollSnapshot(BoardInputSnapshot* out_snapshot) const +{ + if (!out_snapshot) + { + return false; + } + + const auto& inputs = kBoardProfile.inputs; + BoardInputSnapshot snapshot{}; + snapshot.button_primary = readActiveLowPin(inputs.button_primary, inputs.buttons_need_pullup); + snapshot.button_secondary = readActiveLowPin(inputs.button_secondary, inputs.buttons_need_pullup); + snapshot.joystick_up = readActiveLowPin(inputs.joystick_up, inputs.joystick_need_pullup); + snapshot.joystick_down = readActiveLowPin(inputs.joystick_down, inputs.joystick_need_pullup); + snapshot.joystick_left = readActiveLowPin(inputs.joystick_left, inputs.joystick_need_pullup); + snapshot.joystick_right = readActiveLowPin(inputs.joystick_right, inputs.joystick_need_pullup); + snapshot.joystick_press = readActiveLowPin(inputs.joystick_press, inputs.joystick_need_pullup); + snapshot.any_activity = snapshot.button_primary || snapshot.button_secondary || + snapshot.joystick_up || snapshot.joystick_down || + snapshot.joystick_left || snapshot.joystick_right || + snapshot.joystick_press; + + *out_snapshot = snapshot; + return snapshot.any_activity; +} + +uint16_t InputRuntime::debounceMs() const +{ + return kBoardProfile.inputs.debounce_ms; +} + +bool InputRuntime::updateDebounced(bool sampled, + DebounceState& state, + uint16_t debounce_ms, + BoardInputKey key, + BoardInputEvent* out_event, + uint32_t now_ms, + State& runtime_state) +{ + if (sampled != state.sampled) + { + state.sampled = sampled; + state.changed_at_ms = now_ms; + } + + if (state.stable == state.sampled) + { + return false; + } + + if ((now_ms - state.changed_at_ms) < debounce_ms) + { + return false; + } + + state.stable = state.sampled; + if (out_event) + { + out_event->key = key; + out_event->pressed = state.stable; + out_event->timestamp_ms = now_ms; + } + if (state.stable) + { + runtime_state.last_activity_ms = now_ms; + } + return true; +} + +bool InputRuntime::pollEvent(BoardInputEvent* out_event) +{ + if (out_event) + { + *out_event = BoardInputEvent{}; + } + + BoardInputSnapshot current{}; + (void)pollSnapshot(¤t); + state_.snapshot = current; + + const uint32_t now_ms = millis(); + const uint16_t debounce_ms = debounceMs(); + + return updateDebounced(current.button_primary, state_.button_primary, debounce_ms, + BoardInputKey::PrimaryButton, out_event, now_ms, state_) || + updateDebounced(current.button_secondary, state_.button_secondary, debounce_ms, + BoardInputKey::SecondaryButton, out_event, now_ms, state_) || + updateDebounced(current.joystick_up, state_.joystick_up, debounce_ms, + BoardInputKey::JoystickUp, out_event, now_ms, state_) || + updateDebounced(current.joystick_down, state_.joystick_down, debounce_ms, + BoardInputKey::JoystickDown, out_event, now_ms, state_) || + updateDebounced(current.joystick_left, state_.joystick_left, debounce_ms, + BoardInputKey::JoystickLeft, out_event, now_ms, state_) || + updateDebounced(current.joystick_right, state_.joystick_right, debounce_ms, + BoardInputKey::JoystickRight, out_event, now_ms, state_) || + updateDebounced(current.joystick_press, state_.joystick_press, debounce_ms, + BoardInputKey::JoystickPress, out_event, now_ms, state_); +} + +} // namespace boards::gat562_mesh_evb_pro diff --git a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp index 66008381..0fbb4666 100644 --- a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp +++ b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp @@ -1,5 +1,7 @@ #include "boards/gat562_mesh_evb_pro/gat562_board.h" +#include "app/app_facade_access.h" +#include "chat/usecase/contact_service.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" @@ -118,6 +120,26 @@ void handle_low_battery(const BatteryInfo& info) (void)info; } +bool supports_screen_brightness() +{ + return false; +} + +uint8_t screen_brightness() +{ + return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().getBrightness(); +} + +void set_screen_brightness(uint8_t level) +{ + ::boards::gat562_mesh_evb_pro::Gat562Board::instance().setBrightness(level); +} + +void trigger_haptic() +{ + ::boards::gat562_mesh_evb_pro::Gat562Board::instance().vibrator(); +} + uint8_t default_message_tone_volume() { return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().getMessageToneVolume(); @@ -158,14 +180,94 @@ int power_tier() namespace platform::ui::gps { +namespace +{ + +void logGnssSnapshotUiFlow(bool has_snapshot, std::size_t sat_count, const GnssStatus& status) +{ + static bool s_last_has_snapshot = false; + static std::size_t s_last_sat_count = static_cast(-1); + static uint8_t s_last_sats_in_view = 0xFF; + static uint8_t s_last_sats_in_use = 0xFF; + static uint32_t s_last_log_ms = 0; + + const uint32_t now_ms = millis(); + const bool changed = (has_snapshot != s_last_has_snapshot) || (sat_count != s_last_sat_count) || + (status.sats_in_view != s_last_sats_in_view) || (status.sats_in_use != s_last_sats_in_use); + const bool suspicious_full_scale = + has_snapshot && sat_count == ::gps::kMaxGnssSats && status.sats_in_view != static_cast(sat_count); + if (!changed && !suspicious_full_scale && (now_ms - s_last_log_ms) < 2000U) + { + return; + } + + s_last_has_snapshot = has_snapshot; + s_last_sat_count = sat_count; + s_last_sats_in_view = status.sats_in_view; + s_last_sats_in_use = status.sats_in_use; + s_last_log_ms = now_ms; + + Serial.printf("[gat562][gps][ui] snapshot has=%u count=%u status_view=%u status_use=%u hdop=%.1f fix=%u\n", + static_cast(has_snapshot ? 1 : 0), + static_cast(sat_count), + static_cast(status.sats_in_view), + static_cast(status.sats_in_use), + static_cast(status.hdop), + static_cast(status.fix != ::gps::GnssFix::NOFIX ? 1 : 0)); +} + +} // namespace + GpsState get_data() { - return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().gpsData(); + GpsState gps = ::boards::gat562_mesh_evb_pro::Gat562Board::instance().gpsData(); + if (gps.valid) + { + return gps; + } + + if (!::app::hasAppFacade()) + { + return gps; + } + + auto& facade = ::app::appFacade(); + const ::chat::NodeId self_id = facade.getSelfNodeId(); + if (self_id == 0) + { + return gps; + } + + const ::chat::contacts::NodeInfo* self = facade.getContactService().getNodeInfo(self_id); + if (!self || !self->position.valid) + { + return gps; + } + + gps.valid = true; + gps.lat = static_cast(self->position.latitude_i) / 1e7; + gps.lng = static_cast(self->position.longitude_i) / 1e7; + gps.has_alt = self->position.has_altitude; + gps.alt_m = self->position.has_altitude ? static_cast(self->position.altitude) : 0.0; + gps.has_speed = false; + gps.speed_mps = 0.0; + gps.has_course = false; + gps.course_deg = 0.0; + gps.satellites = 0; + gps.age = 0xFFFFFFFFUL; + return gps; } bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count, GnssStatus* status) { - return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().gpsGnssSnapshot(out, max, out_count, status); + GnssStatus local_status{}; + std::size_t local_count = 0; + const bool has_snapshot = ::boards::gat562_mesh_evb_pro::Gat562Board::instance().gpsGnssSnapshot( + out, max, out_count ? out_count : &local_count, status ? status : &local_status); + const std::size_t final_count = out_count ? *out_count : local_count; + const GnssStatus& final_status = status ? *status : local_status; + logGnssSnapshotUiFlow(has_snapshot, final_count, final_status); + return has_snapshot; } uint32_t last_motion_ms() diff --git a/boards/gat562_mesh_evb_pro/src/settings_store.cpp b/boards/gat562_mesh_evb_pro/src/settings_store.cpp index 1c8ebeb3..ae8c3305 100644 --- a/boards/gat562_mesh_evb_pro/src/settings_store.cpp +++ b/boards/gat562_mesh_evb_pro/src/settings_store.cpp @@ -3,6 +3,7 @@ #include "chat/infra/mesh_protocol_utils.h" #include "chat/infra/meshcore/mc_region_presets.h" #include "chat/infra/meshtastic/mt_region.h" +#include "platform/nrf52/arduino_common/internal_fs_utils.h" #include #include @@ -15,21 +16,31 @@ namespace boards::gat562_mesh_evb_pro::settings_store namespace { using Adafruit_LittleFS_Namespace::FILE_O_READ; -using Adafruit_LittleFS_Namespace::FILE_O_WRITE; constexpr const char* kSettingsPath = "/gat562_settings.bin"; -constexpr const char* kSettingsTempPath = "/gat562_settings.bin.tmp"; -constexpr const char* kSettingsBackupPath = "/gat562_settings.bin.bak"; constexpr const char* kSettingsCorruptPath = "/gat562_settings.bin.corrupt"; +constexpr const char* kLogTag = "[gat562][settings]"; constexpr uint32_t kSettingsMagic = 0x53415447UL; // GTAS -constexpr uint16_t kSettingsVersion = 1; +constexpr uint16_t kSettingsVersion = 2; constexpr uint8_t kDefaultToneVolume = 45; +constexpr uint32_t kDeferredSaveDebounceMs = 1500UL; +constexpr uint32_t kImmediateSaveRetryDelayMs = 20UL; + +struct PersistedPayloadV1 +{ + app::AppConfig config; + uint8_t tone_volume = kDefaultToneVolume; + uint8_t reserved[3] = {}; +}; struct PersistedPayload { app::AppConfig config; uint8_t tone_volume = kDefaultToneVolume; - uint8_t reserved[3] = {}; + uint8_t has_meshtastic_ble_state = 0; + uint8_t reserved[2] = {}; + meshtastic_Config_BluetoothConfig meshtastic_ble_bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + meshtastic_LocalModuleConfig meshtastic_ble_module = meshtastic_LocalModuleConfig_init_zero; }; struct FileHeader @@ -45,12 +56,19 @@ struct CachedSettings { app::AppConfig config; uint8_t tone_volume = kDefaultToneVolume; + bool has_meshtastic_ble_state = false; + meshtastic_Config_BluetoothConfig meshtastic_ble_bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + meshtastic_LocalModuleConfig meshtastic_ble_module = meshtastic_LocalModuleConfig_init_zero; }; bool s_cache_loaded = false; CachedSettings s_cache{}; StoreStatus s_last_load_status = StoreStatus::NotFound; StoreStatus s_last_save_status = StoreStatus::NotFound; +bool s_deferred_save_pending = false; +uint32_t s_last_dirty_ms = 0; +bool s_save_in_progress = false; +uint32_t s_last_save_attempt_ms = 0; int8_t clampTxPower(int8_t value) { @@ -105,24 +123,6 @@ const char* statusText(StoreStatus status) } } -bool ensureFs() -{ - if (InternalFS.begin()) - { - return true; - } - Serial.printf("[gat562][settings] fs init failed\n"); - return false; -} - -void removeIfExists(const char* path) -{ - if (path && InternalFS.exists(path)) - { - InternalFS.remove(path); - } -} - uint32_t crc32(const uint8_t* data, size_t len) { uint32_t crc = 0xFFFFFFFFU; @@ -144,7 +144,7 @@ bool quarantineCorruptFile(StoreStatus status) return true; } - removeIfExists(kSettingsCorruptPath); + ::platform::nrf52::arduino_common::internal_fs::removeIfExists(kSettingsCorruptPath); if (InternalFS.rename(kSettingsPath, kSettingsCorruptPath)) { Serial.printf("[gat562][settings] quarantined corrupt store status=%s path=%s\n", @@ -162,12 +162,101 @@ void resetCacheToDefaults() { s_cache = CachedSettings{}; s_cache.tone_volume = kDefaultToneVolume; + s_cache.has_meshtastic_ble_state = false; + s_cache.meshtastic_ble_bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + meshtastic_LocalModuleConfig zero_module = meshtastic_LocalModuleConfig_init_zero; + s_cache.meshtastic_ble_module = zero_module; normalizeConfig(s_cache.config); } +bool verifySavedFile() +{ + auto file = InternalFS.open(kSettingsPath, FILE_O_READ); + if (!file) + { + Serial.printf("[gat562][settings] verify open failed path=%s\n", kSettingsPath); + return false; + } + + const uint32_t actual_size = file.size(); + const uint32_t expected_size = + static_cast(sizeof(FileHeader) + sizeof(PersistedPayload)); + + if (actual_size != expected_size) + { + file.close(); + Serial.printf("[gat562][settings] verify size mismatch actual=%lu expected=%lu\n", + static_cast(actual_size), + static_cast(expected_size)); + return false; + } + + FileHeader header{}; + if (file.read(&header, sizeof(header)) != sizeof(header)) + { + file.close(); + Serial.printf("[gat562][settings] verify header read failed\n"); + return false; + } + + if (header.magic != kSettingsMagic) + { + file.close(); + Serial.printf("[gat562][settings] verify magic mismatch got=0x%08lX expected=0x%08lX\n", + static_cast(header.magic), + static_cast(kSettingsMagic)); + return false; + } + + if (header.version != kSettingsVersion) + { + file.close(); + Serial.printf("[gat562][settings] verify version mismatch got=%u expected=%u\n", + static_cast(header.version), + static_cast(kSettingsVersion)); + return false; + } + + if (header.payload_size != sizeof(PersistedPayload)) + { + file.close(); + Serial.printf("[gat562][settings] verify payload size mismatch got=%lu expected=%lu\n", + static_cast(header.payload_size), + static_cast(sizeof(PersistedPayload))); + return false; + } + + PersistedPayload payload{}; + if (file.read(&payload, sizeof(payload)) != sizeof(payload)) + { + file.close(); + Serial.printf("[gat562][settings] verify payload read failed\n"); + return false; + } + + file.close(); + + const uint32_t actual_crc = + crc32(reinterpret_cast(&payload), sizeof(payload)); + + if (actual_crc != header.crc32) + { + Serial.printf("[gat562][settings] verify crc mismatch got=0x%08lX expected=0x%08lX\n", + static_cast(actual_crc), + static_cast(header.crc32)); + return false; + } + + Serial.printf("[gat562][settings] verify ok size=%lu crc=0x%08lX mt_ble=%u\n", + static_cast(actual_size), + static_cast(actual_crc), + payload.has_meshtastic_ble_state ? 1U : 0U); + return true; +} + bool loadFromFs() { - if (!ensureFs()) + if (!::platform::nrf52::arduino_common::internal_fs::ensureMounted(true, kLogTag)) { s_last_load_status = StoreStatus::FsInitFailed; return false; @@ -187,16 +276,15 @@ bool loadFromFs() return false; } - const uint32_t expected_size = static_cast(sizeof(FileHeader) + sizeof(PersistedPayload)); const uint32_t actual_size = file.size(); - if (actual_size != expected_size) + if (actual_size < sizeof(FileHeader)) { file.close(); s_last_load_status = StoreStatus::PayloadSizeMismatch; (void)quarantineCorruptFile(s_last_load_status); - Serial.printf("[gat562][settings] size mismatch actual=%lu expected=%lu\n", + Serial.printf("[gat562][settings] size mismatch actual=%lu expected_at_least=%lu\n", static_cast(actual_size), - static_cast(expected_size)); + static_cast(sizeof(FileHeader))); return false; } @@ -222,6 +310,58 @@ bool loadFromFs() if (header.version != kSettingsVersion) { + if (header.version == 1) + { + const uint32_t expected_size_v1 = + static_cast(sizeof(FileHeader) + sizeof(PersistedPayloadV1)); + if (header.payload_size != sizeof(PersistedPayloadV1) || actual_size != expected_size_v1) + { + file.close(); + s_last_load_status = StoreStatus::PayloadSizeMismatch; + (void)quarantineCorruptFile(s_last_load_status); + Serial.printf("[gat562][settings] v1 payload size mismatch got=%lu expected=%lu actual=%lu\n", + static_cast(header.payload_size), + static_cast(sizeof(PersistedPayloadV1)), + static_cast(actual_size)); + return false; + } + + PersistedPayloadV1 payload_v1{}; + if (file.read(&payload_v1, sizeof(payload_v1)) != sizeof(payload_v1)) + { + file.close(); + s_last_load_status = StoreStatus::ReadFailed; + Serial.printf("[gat562][settings] v1 payload read failed\n"); + return false; + } + file.close(); + + const uint32_t actual_crc = crc32(reinterpret_cast(&payload_v1), sizeof(payload_v1)); + if (actual_crc != header.crc32) + { + s_last_load_status = StoreStatus::CrcMismatch; + (void)quarantineCorruptFile(s_last_load_status); + Serial.printf("[gat562][settings] v1 crc mismatch got=0x%08lX expected=0x%08lX\n", + static_cast(actual_crc), + static_cast(header.crc32)); + return false; + } + + s_cache.config = payload_v1.config; + normalizeConfig(s_cache.config); + s_cache.tone_volume = clampToneVolume(payload_v1.tone_volume); + s_cache.has_meshtastic_ble_state = false; + s_cache.meshtastic_ble_bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + meshtastic_LocalModuleConfig zero_module = meshtastic_LocalModuleConfig_init_zero; + s_cache.meshtastic_ble_module = zero_module; + s_last_load_status = StoreStatus::Ok; + Serial.printf("[gat562][settings] load ok tone=%u ble=%u proto=%u mt_ble=0 version=1\n", + static_cast(s_cache.tone_volume), + static_cast(s_cache.config.ble_enabled ? 1 : 0), + static_cast(s_cache.config.mesh_protocol)); + return true; + } + file.close(); s_last_load_status = StoreStatus::VersionMismatch; (void)quarantineCorruptFile(s_last_load_status); @@ -231,14 +371,16 @@ bool loadFromFs() return false; } - if (header.payload_size != sizeof(PersistedPayload)) + if (header.payload_size != sizeof(PersistedPayload) || + actual_size != static_cast(sizeof(FileHeader) + sizeof(PersistedPayload))) { file.close(); s_last_load_status = StoreStatus::PayloadSizeMismatch; (void)quarantineCorruptFile(s_last_load_status); - Serial.printf("[gat562][settings] payload size mismatch got=%lu expected=%lu\n", + Serial.printf("[gat562][settings] payload size mismatch got=%lu expected=%lu actual=%lu\n", static_cast(header.payload_size), - static_cast(sizeof(PersistedPayload))); + static_cast(sizeof(PersistedPayload)), + static_cast(actual_size)); return false; } @@ -266,94 +408,172 @@ bool loadFromFs() s_cache.config = payload.config; normalizeConfig(s_cache.config); s_cache.tone_volume = clampToneVolume(payload.tone_volume); + s_cache.has_meshtastic_ble_state = (payload.has_meshtastic_ble_state != 0); + s_cache.meshtastic_ble_bluetooth = payload.meshtastic_ble_bluetooth; + s_cache.meshtastic_ble_module = payload.meshtastic_ble_module; s_last_load_status = StoreStatus::Ok; - Serial.printf("[gat562][settings] load ok tone=%u ble=%u proto=%u\n", + Serial.printf("[gat562][settings] load ok tone=%u ble=%u proto=%u mt_ble=%u version=%u\n", static_cast(s_cache.tone_volume), static_cast(s_cache.config.ble_enabled ? 1 : 0), - static_cast(s_cache.config.mesh_protocol)); + static_cast(s_cache.config.mesh_protocol), + s_cache.has_meshtastic_ble_state ? 1U : 0U, + static_cast(header.version)); return true; } -bool saveToFs() +bool saveToFsOnce() { - if (!ensureFs()) + Serial.printf("[gat562][settings] save begin path=%s\n", kSettingsPath); + + if (!::platform::nrf52::arduino_common::internal_fs::ensureMounted(true, kLogTag)) { s_last_save_status = StoreStatus::FsInitFailed; - return false; - } - - removeIfExists(kSettingsTempPath); - - auto file = InternalFS.open(kSettingsTempPath, FILE_O_WRITE); - if (!file) - { - s_last_save_status = StoreStatus::OpenFailed; - Serial.printf("[gat562][settings] temp open failed path=%s\n", kSettingsTempPath); + Serial.printf("%s ensureMounted failed\n", kLogTag); return false; } PersistedPayload payload{}; payload.config = s_cache.config; payload.tone_volume = clampToneVolume(s_cache.tone_volume); + payload.has_meshtastic_ble_state = s_cache.has_meshtastic_ble_state ? 1U : 0U; + payload.reserved[0] = 0; + payload.reserved[1] = 0; + payload.meshtastic_ble_bluetooth = s_cache.meshtastic_ble_bluetooth; + payload.meshtastic_ble_module = s_cache.meshtastic_ble_module; FileHeader header{}; header.magic = kSettingsMagic; header.version = kSettingsVersion; - header.payload_size = sizeof(payload); - header.crc32 = crc32(reinterpret_cast(&payload), sizeof(payload)); + header.reserved = 0; + header.payload_size = sizeof(PersistedPayload); + header.crc32 = crc32(reinterpret_cast(&payload), sizeof(PersistedPayload)); + + // 先尝试正常打开已有文件 + Adafruit_LittleFS_Namespace::File file(InternalFS); + if (!::platform::nrf52::arduino_common::internal_fs::openForOverwrite(kSettingsPath, &file, true, kLogTag)) + { + s_last_save_status = StoreStatus::OpenFailed; + Serial.printf("%s open failed path=%s\n", kLogTag, kSettingsPath); + return false; + } + + { + auto oldf = InternalFS.open(kSettingsPath, FILE_O_READ); + if (oldf) + { + Serial.printf("[gat562][settings] old size before overwrite=%lu\n", + static_cast(oldf.size())); + oldf.close(); + } + } + + const bool seek_ok = ::platform::nrf52::arduino_common::internal_fs::rewindForOverwrite(file); + Serial.printf("[gat562][settings] seek0 ok=%u\n", seek_ok ? 1U : 0U); + if (!seek_ok) + { + file.close(); + s_last_save_status = StoreStatus::WriteFailed; + Serial.printf("[gat562][settings] seek failed path=%s\n", kSettingsPath); + return false; + } + + const size_t header_written = + file.write(reinterpret_cast(&header), sizeof(FileHeader)); + + const size_t payload_written = + (header_written == sizeof(FileHeader)) + ? file.write(reinterpret_cast(&payload), sizeof(PersistedPayload)) + : 0U; + + const uint32_t final_size = + static_cast(sizeof(FileHeader) + sizeof(PersistedPayload)); + + bool trunc_ok = false; + if (header_written == sizeof(FileHeader) && + payload_written == sizeof(PersistedPayload)) + { + trunc_ok = ::platform::nrf52::arduino_common::internal_fs::truncateAfterWrite(file, final_size); + } - const size_t header_written = file.write(reinterpret_cast(&header), sizeof(header)); - const size_t payload_written = header_written == sizeof(header) - ? file.write(reinterpret_cast(&payload), sizeof(payload)) - : 0U; file.flush(); - const bool write_ok = (header_written == sizeof(header) && payload_written == sizeof(payload)); file.close(); - if (!write_ok) + if (header_written != sizeof(FileHeader) || + payload_written != sizeof(PersistedPayload)) { - removeIfExists(kSettingsTempPath); s_last_save_status = StoreStatus::WriteFailed; - Serial.printf("[gat562][settings] write failed header=%lu payload=%lu\n", + Serial.printf("[gat562][settings] write failed header=%lu payload=%lu expected_header=%lu expected_payload=%lu crc=0x%08lx exists=%u\n", static_cast(header_written), - static_cast(payload_written)); + static_cast(payload_written), + static_cast(sizeof(FileHeader)), + static_cast(sizeof(PersistedPayload)), + static_cast(header.crc32), + InternalFS.exists(kSettingsPath) ? 1U : 0U); return false; } - removeIfExists(kSettingsBackupPath); - if (InternalFS.exists(kSettingsPath) && !InternalFS.rename(kSettingsPath, kSettingsBackupPath)) + if (!trunc_ok) { - removeIfExists(kSettingsTempPath); - s_last_save_status = StoreStatus::BackupFailed; - Serial.printf("[gat562][settings] backup rename failed main=%s backup=%s\n", - kSettingsPath, - kSettingsBackupPath); + s_last_save_status = StoreStatus::WriteFailed; + Serial.printf("[gat562][settings] truncate failed target=%lu\n", + static_cast(final_size)); return false; } - if (!InternalFS.rename(kSettingsTempPath, kSettingsPath)) + if (!verifySavedFile()) { - if (InternalFS.exists(kSettingsBackupPath)) - { - (void)InternalFS.rename(kSettingsBackupPath, kSettingsPath); - } - removeIfExists(kSettingsTempPath); - s_last_save_status = StoreStatus::RenameFailed; - Serial.printf("[gat562][settings] commit rename failed temp=%s main=%s\n", - kSettingsTempPath, - kSettingsPath); + s_last_save_status = StoreStatus::WriteFailed; + Serial.printf("[gat562][settings] verify after save failed\n"); return false; } - removeIfExists(kSettingsBackupPath); s_last_save_status = StoreStatus::Ok; - Serial.printf("[gat562][settings] save ok tone=%u ble=%u proto=%u\n", + Serial.printf("[gat562][settings] save ok size=%lu crc=0x%08lx tone=%u mt_ble=%u\n", + static_cast(sizeof(PersistedPayload)), + static_cast(header.crc32), static_cast(payload.tone_volume), - static_cast(payload.config.ble_enabled ? 1 : 0), - static_cast(payload.config.mesh_protocol)); + static_cast(payload.has_meshtastic_ble_state)); return true; } +bool saveToFs() +{ + if (s_save_in_progress) + { + Serial.printf("[gat562][settings] save skipped: already in progress\n"); + return false; + } + + s_save_in_progress = true; + s_last_save_attempt_ms = millis(); + + bool ok = saveToFsOnce(); + if (!ok) + { + Serial.printf("[gat562][settings] save first attempt failed status=%s retry_delay_ms=%lu\n", + statusText(s_last_save_status), + static_cast(kImmediateSaveRetryDelayMs)); + + delay(kImmediateSaveRetryDelayMs); + + ok = saveToFsOnce(); + if (!ok) + { + Serial.printf("[gat562][settings] save retry failed status=%s\n", + statusText(s_last_save_status)); + } + } + + s_save_in_progress = false; + return ok; +} + +void markDeferredSaveDirty() +{ + s_deferred_save_pending = true; + s_last_dirty_ms = millis(); +} + void ensureCacheLoaded() { if (s_cache_loaded) @@ -455,9 +675,18 @@ bool saveAppConfig(const app::AppConfig& config) ensureCacheLoaded(); s_cache.config = config; normalizeConfig(s_cache.config); + s_deferred_save_pending = false; return saveToFs(); } +void queueSaveAppConfig(const app::AppConfig& config) +{ + ensureCacheLoaded(); + s_cache.config = config; + normalizeConfig(s_cache.config); + markDeferredSaveDirty(); +} + uint8_t loadMessageToneVolume() { ensureCacheLoaded(); @@ -468,9 +697,104 @@ bool saveMessageToneVolume(uint8_t volume) { ensureCacheLoaded(); s_cache.tone_volume = clampToneVolume(volume); + s_deferred_save_pending = false; return saveToFs(); } +void queueSaveMessageToneVolume(uint8_t volume) +{ + ensureCacheLoaded(); + s_cache.tone_volume = clampToneVolume(volume); + markDeferredSaveDirty(); +} + +bool loadMeshtasticBleState(meshtastic_Config_BluetoothConfig* bluetooth, + meshtastic_LocalModuleConfig* module) +{ + ensureCacheLoaded(); + + Serial.printf("[gat562][settings][mt] load request has_state=%u last_load=%s\n", + s_cache.has_meshtastic_ble_state ? 1U : 0U, + statusText(s_last_load_status)); + + if (!bluetooth || !module || !s_cache.has_meshtastic_ble_state) + { + return false; + } + + *bluetooth = s_cache.meshtastic_ble_bluetooth; + *module = s_cache.meshtastic_ble_module; + + Serial.printf("[gat562][settings][mt] load ok mode=%u mqtt=%u proxy=%u root=%s\n", + static_cast(bluetooth->mode), + module->has_mqtt && module->mqtt.enabled ? 1U : 0U, + module->has_mqtt && module->mqtt.proxy_to_client_enabled ? 1U : 0U, + module->mqtt.root); + return true; +} + +bool saveMeshtasticBleState(const meshtastic_Config_BluetoothConfig& bluetooth, + const meshtastic_LocalModuleConfig& module) +{ + ensureCacheLoaded(); + s_cache.has_meshtastic_ble_state = true; + s_cache.meshtastic_ble_bluetooth = bluetooth; + s_cache.meshtastic_ble_module = module; + + s_deferred_save_pending = false; + const bool ok = saveToFs(); + if (!ok) + { + s_deferred_save_pending = true; + s_last_dirty_ms = millis(); + Serial.printf("[gat562][settings][mt] save failed status=%s\n", statusText(s_last_save_status)); + return false; + } + + Serial.printf("[gat562][settings][mt] save ok mode=%u mqtt=%u proxy=%u root=%s\n", + static_cast(bluetooth.mode), + module.has_mqtt && module.mqtt.enabled ? 1U : 0U, + module.has_mqtt && module.mqtt.proxy_to_client_enabled ? 1U : 0U, + module.mqtt.root); + return true; +} + +bool tickDeferredSave() +{ + ensureCacheLoaded(); + if (!s_deferred_save_pending) + { + return false; + } + + if (s_save_in_progress) + { + return false; + } + + const uint32_t now_ms = millis(); + if ((now_ms - s_last_dirty_ms) < kDeferredSaveDebounceMs) + { + return false; + } + + s_deferred_save_pending = false; + if (saveToFs()) + { + return true; + } + + s_deferred_save_pending = true; + s_last_dirty_ms = millis(); + return false; +} + +bool hasDeferredSavePending() +{ + ensureCacheLoaded(); + return s_deferred_save_pending; +} + StoreStatus lastLoadStatus() { return s_last_load_status; diff --git a/boards/lilygo-t-lora-pager.json b/boards/lilygo-t-lora-pager.json index 7a4a60e6..2393ecf5 100644 --- a/boards/lilygo-t-lora-pager.json +++ b/boards/lilygo-t-lora-pager.json @@ -18,7 +18,7 @@ "flash_mode": "qio", "hwids": [["0x303A", "0x82D4"]], "mcu": "esp32s3", - "variant": "lilygo_twatch_ultra", + "variant": "lilygo_tlora_pager", "variants_dir": "variants" }, "connectivity": ["wifi"], diff --git a/boards/lilygo-t-watch-s3.json b/boards/lilygo-t-watch-s3.json index 22e5a8fa..4f7cc765 100644 --- a/boards/lilygo-t-watch-s3.json +++ b/boards/lilygo-t-watch-s3.json @@ -18,7 +18,8 @@ "flash_mode": "qio", "hwids": [["0x303A", "0x1001"]], "mcu": "esp32s3", - "variant": "esp32s3" + "variant": "lilygo_twatch_s3", + "variants_dir": "variants" }, "connectivity": ["wifi"], "debug": { diff --git a/boards/tdeck_pro/include/boards/tdeck_pro/board_profile.h b/boards/tdeck_pro/include/boards/tdeck_pro/board_profile.h new file mode 100644 index 00000000..c9ce5f95 --- /dev/null +++ b/boards/tdeck_pro/include/boards/tdeck_pro/board_profile.h @@ -0,0 +1,221 @@ +#pragma once + +#include + +namespace boards::tdeck_pro +{ + +struct BoardProfile +{ + struct ProductIdentity + { + const char* long_name; + const char* short_name; + const char* ble_name; + }; + + struct I2cPins + { + int sda; + int scl; + }; + + struct SpiPins + { + int sck; + int mosi; + int miso; + }; + + struct UartPins + { + int tx; + int rx; + uint32_t baud; + }; + + struct EpdPins + { + int cs; + int dc; + int rst; + int busy; + }; + + struct TouchPins + { + int int_pin; + int rst; + uint8_t i2c_addr; + }; + + struct KeyboardPins + { + int int_pin; + int led; + uint8_t i2c_addr; + uint8_t rows; + uint8_t cols; + }; + + struct LoraPins + { + int cs; + int busy; + int rst; + int irq; + int enable; + }; + + struct GpsPins + { + int enable; + int pps; + UartPins uart; + }; + + struct MotionPins + { + int enable_1v8; + int int_pin; + uint8_t i2c_addr; + }; + + struct SdPins + { + int cs; + }; + + struct OptionalModulePins + { + bool has_a7682e; + bool has_pcm512a; + int a7682e_enable; + int a7682e_pwrkey; + int a7682e_rst; + int a7682e_ri; + int a7682e_dtr; + UartPins a7682e_uart; + int i2s_bclk; + int i2s_lrc; + int i2s_dout; + }; + + ProductIdentity identity; + I2cPins i2c; + SpiPins spi; + EpdPins epd; + TouchPins touch; + KeyboardPins keyboard; + LoraPins lora; + GpsPins gps; + MotionPins motion; + SdPins sd; + OptionalModulePins optional; + int motor_pin; + int keyboard_default_brightness; + int screen_width; + int screen_height; +}; + +inline BoardProfile makeA7682EProfile() +{ + BoardProfile profile{}; + profile.identity.long_name = "Trail Mate T-Deck Pro A7682E"; + profile.identity.short_name = "TDP4G"; + profile.identity.ble_name = "TrailMate-TDeckPro-4G"; + + profile.i2c.sda = 13; + profile.i2c.scl = 14; + + profile.spi.sck = 36; + profile.spi.mosi = 33; + profile.spi.miso = 47; + + profile.epd.cs = 34; + profile.epd.dc = 35; + profile.epd.rst = -1; + profile.epd.busy = 37; + + profile.touch.int_pin = 12; + profile.touch.rst = 45; + profile.touch.i2c_addr = 0x1A; + + profile.keyboard.int_pin = 15; + profile.keyboard.led = 42; + profile.keyboard.i2c_addr = 0x34; + profile.keyboard.rows = 4; + profile.keyboard.cols = 10; + + profile.lora.cs = 3; + profile.lora.busy = 6; + profile.lora.rst = 4; + profile.lora.irq = 5; + profile.lora.enable = 46; + + profile.gps.enable = 39; + profile.gps.pps = 1; + profile.gps.uart.tx = 43; + profile.gps.uart.rx = 44; + profile.gps.uart.baud = 38400; + + profile.motion.enable_1v8 = 38; + profile.motion.int_pin = 21; + profile.motion.i2c_addr = 0x28; + + profile.sd.cs = 48; + + profile.optional.has_a7682e = true; + profile.optional.has_pcm512a = false; + profile.optional.a7682e_enable = 41; + profile.optional.a7682e_pwrkey = 40; + profile.optional.a7682e_rst = 9; + profile.optional.a7682e_ri = 7; + profile.optional.a7682e_dtr = 8; + profile.optional.a7682e_uart.tx = 11; + profile.optional.a7682e_uart.rx = 10; + profile.optional.a7682e_uart.baud = 115200; + profile.optional.i2s_bclk = -1; + profile.optional.i2s_lrc = -1; + profile.optional.i2s_dout = -1; + + profile.motor_pin = 2; + profile.keyboard_default_brightness = 96; + profile.screen_width = 240; + profile.screen_height = 320; + return profile; +} + +inline BoardProfile makePCM512AProfile() +{ + BoardProfile profile = makeA7682EProfile(); + profile.identity.long_name = "Trail Mate T-Deck Pro PCM512A"; + profile.identity.short_name = "TDPA"; + profile.identity.ble_name = "TrailMate-TDeckPro-Audio"; + + profile.optional.has_a7682e = false; + profile.optional.has_pcm512a = true; + profile.optional.a7682e_enable = -1; + profile.optional.a7682e_pwrkey = -1; + profile.optional.a7682e_rst = -1; + profile.optional.a7682e_ri = -1; + profile.optional.a7682e_dtr = -1; + profile.optional.a7682e_uart.tx = -1; + profile.optional.a7682e_uart.rx = -1; + profile.optional.a7682e_uart.baud = 0; + profile.optional.i2s_bclk = 7; + profile.optional.i2s_lrc = 9; + profile.optional.i2s_dout = 8; + profile.motor_pin = 40; + return profile; +} + +#if defined(TRAIL_MATE_TDECK_PRO_A7682E) +static const BoardProfile kBoardProfile = makeA7682EProfile(); +#elif defined(TRAIL_MATE_TDECK_PRO_PCM512A) +static const BoardProfile kBoardProfile = makePCM512AProfile(); +#else +#error "T-Deck Pro profile requires TRAIL_MATE_TDECK_PRO_A7682E or TRAIL_MATE_TDECK_PRO_PCM512A" +#endif + +} // namespace boards::tdeck_pro diff --git a/boards/tdeck_pro/include/boards/tdeck_pro/platform_esp_board_runtime.h b/boards/tdeck_pro/include/boards/tdeck_pro/platform_esp_board_runtime.h new file mode 100644 index 00000000..436b6058 --- /dev/null +++ b/boards/tdeck_pro/include/boards/tdeck_pro/platform_esp_board_runtime.h @@ -0,0 +1,36 @@ +#pragma once + +#include "boards/tdeck_pro/tdeck_pro_board.h" +#include "platform/esp/boards/board_runtime.h" +#include "ui/LV_Helper.h" + +namespace platform::esp::boards::detail +{ + +inline void initializeBoard(bool waking_from_sleep) +{ + (void)waking_from_sleep; + ::boards::tdeck_pro::board.begin(); +} + +inline void initializeDisplay() +{ + beginLvglHelper(static_cast(::boards::tdeck_pro::instance)); +} + +inline bool tryResolveAppContextInitHandles(AppContextInitHandles* out_handles) +{ + if (!out_handles) + { + return false; + } + *out_handles = resolveAppContextInitHandles(); + return true; +} + +inline AppContextInitHandles resolveAppContextInitHandles() +{ + return {&::boards::tdeck_pro::board, &::boards::tdeck_pro::instance, &::boards::tdeck_pro::instance, &::boards::tdeck_pro::instance}; +} + +} // namespace platform::esp::boards::detail diff --git a/boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h b/boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h new file mode 100644 index 00000000..61140f34 --- /dev/null +++ b/boards/tdeck_pro/include/boards/tdeck_pro/tdeck_pro_board.h @@ -0,0 +1,166 @@ +#pragma once + +#define XPOWERS_CHIP_BQ25896 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "board/BoardBase.h" +#include "board/GpsBoard.h" +#include "board/LoraBoard.h" +#include "board/MotionBoard.h" +#include "board/SdBoard.h" +#include "board/TLoRaPagerTypes.h" +#include "boards/tdeck_pro/board_profile.h" +#include "display/DisplayInterface.h" +#include "platform/esp/arduino_common/gps/GPS.h" + +namespace boards::tdeck_pro +{ + +class SX1262Access : public SX1262 +{ + public: + using SX1262::SX1262; + using SX126x::readRegister; + using SX126x::setTxParams; + using SX126x::writeRegister; +}; + +class TDeckProBoard : public BoardBase, + public LoraBoard, + public GpsBoard, + public MotionBoard, + public SdBoard, + public LilyGo_Display +{ + public: + static TDeckProBoard* getInstance(); + static constexpr const BoardProfile& profile() + { + return kBoardProfile; + } + + uint32_t begin(uint32_t disable_hw_init = 0) override; + void wakeUp() override {} + void handlePowerButton() override {} + void softwareShutdown() override {} + + void setBrightness(uint8_t level) override; + uint8_t getBrightness() override { return brightness_; } + + bool hasKeyboard() override { return keyboard_ready_; } + void keyboardSetBrightness(uint8_t level) override; + uint8_t keyboardGetBrightness() override { return keyboard_brightness_; } + + bool isRTCReady() const override; + bool isCharging() override; + int getBatteryLevel() override; + + bool isSDReady() const override { return sd_ready_; } + bool isCardReady() override; + bool isGPSReady() const override { return gps_ready_; } + + void vibrator() override; + void stopVibrator() override; + void playMessageTone() override; + void setMessageToneVolume(uint8_t volume_percent) override; + uint8_t getMessageToneVolume() const override; + + void setRotation(uint8_t rotation) override; + uint8_t getRotation() override { return rotation_; } + void pushColors(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t* color) override; + uint16_t width() override; + uint16_t height() override; + bool hasTouch() override { return touch_ready_; } + uint8_t getPoint(int16_t* x, int16_t* y, uint8_t get_point) override; + int getKeyChar(char* c) override; + + bool isRadioOnline() const override { return radio_ready_; } + int transmitRadio(const uint8_t* data, size_t len) override; + int startRadioReceive() override; + uint32_t getRadioIrqFlags() override; + int getRadioPacketLength(bool update) override; + int readRadioData(uint8_t* buf, size_t len) override; + void clearRadioIrqFlags(uint32_t flags) override; + float getRadioRSSI() override; + float getRadioSNR() override; + void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, + int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, + uint8_t crc_len) override; + + bool initGPS() override; + void setGPSOnline(bool online) override { gps_ready_ = online; } + GPS& getGPS() override { return gps_; } + void powerControl(PowerCtrlChannel_t ch, bool enable) override; + bool syncTimeFromGPS(uint32_t gps_task_interval_ms = 0) override; + + SensorBHI260AP& getMotionSensor() override { return motion_; } + bool isSensorReady() const override { return motion_ready_; } + + bool hasCellularModem() const { return profile().optional.has_a7682e; } + bool hasAudioDac() const { return profile().optional.has_pcm512a; } + + private: + TDeckProBoard(); + void initSharedPins(); + bool initPower(); + bool initDisplay(); + bool initTouch(); + bool initKeyboard(); + bool initMotion(); + bool initRadio(); + bool initStorage(); + bool installSD() override; + void uninstallSD() override; + void renderEpd(); + void setBit(int16_t x, int16_t y, bool black); + bool keyEventToChar(uint8_t event, char* c, bool* pressed); + + using EpdPanel = GxEPD2_BW; + + uint8_t brightness_ = DEVICE_MAX_BRIGHTNESS_LEVEL; + uint8_t keyboard_brightness_ = static_cast(kBoardProfile.keyboard_default_brightness); + uint8_t message_tone_volume_ = 45; + uint8_t rotation_ = 0; + bool power_ready_ = false; + bool display_ready_ = false; + bool touch_ready_ = false; + bool keyboard_ready_ = false; + bool radio_ready_ = false; + bool sd_ready_ = false; + bool gps_ready_ = false; + bool motion_ready_ = false; + bool rtc_ready_ = false; + bool battery_gauge_ready_ = false; + int last_battery_level_ = -1; + + SPIClass& shared_spi_ = SPI; + Module lora_module_{static_cast(profile().lora.cs), + static_cast(profile().lora.irq), + static_cast(profile().lora.rst), + static_cast(profile().lora.busy), + shared_spi_}; + SX1262Access radio_{&lora_module_}; + EpdPanel epd_{GxEPD2_310_GDEQ031T10(profile().epd.cs, profile().epd.dc, profile().epd.rst, profile().epd.busy)}; + TouchDrvCSTXXX touch_; + Adafruit_TCA8418 keyboard_; + GPS gps_; + SensorBHI260AP motion_; + PowersBQ25896 pmu_; + GaugeBQ27220 gauge_; + std::vector mono_buffer_; +}; + +extern TDeckProBoard& instance; +extern BoardBase& board; + +} // namespace boards::tdeck_pro diff --git a/boards/tdeck_pro/library.json b/boards/tdeck_pro/library.json new file mode 100644 index 00000000..df517ac3 --- /dev/null +++ b/boards/tdeck_pro/library.json @@ -0,0 +1,57 @@ +{ + "name": "boards_tdeck_pro", + "version": "0.1.0", + "frameworks": [ + "arduino" + ], + "platforms": [ + "espressif32" + ], + "build": { + "includeDir": "include", + "srcDir": "src", + "flags": [ + "-std=gnu++17", + "-Iinclude", + "-I../..", + "-I../../platform/shared/include", + "-I../../platform/esp/boards/include", + "-I../../modules/ui_shared/include", + "-I../../modules/core_sys/include", + "-I../../modules/core_chat/include", + "-I../../modules/core_gps/include", + "-I../../modules/core_hostlink/include", + "-I../../modules/core_team/include", + "-I../../platform/esp/arduino_common/include" + ] + }, + "dependencies": [ + { + "name": "lvgl" + }, + { + "name": "SensorLib" + }, + { + "name": "XPowersLib" + }, + { + "name": "RadioLib" + }, + { + "name": "TinyGPSPlus" + }, + { + "name": "GxEPD2" + }, + { + "name": "Adafruit TCA8418" + }, + { + "name": "Adafruit BusIO" + }, + { + "name": "ESP8266Audio" + } + ] +} diff --git a/boards/tdeck_pro/src/tdeck_pro_board.cpp b/boards/tdeck_pro/src/tdeck_pro_board.cpp new file mode 100644 index 00000000..b2494b9c --- /dev/null +++ b/boards/tdeck_pro/src/tdeck_pro_board.cpp @@ -0,0 +1,851 @@ +#if defined(ARDUINO_T_DECK_PRO) + +#include "boards/tdeck_pro/tdeck_pro_board.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace boards::tdeck_pro +{ + +namespace +{ +constexpr const char* kTag = "TDeckProBoard"; +constexpr time_t kMinValidEpochSeconds = 1577836800; // 2020-01-01 UTC +constexpr uint8_t kKeyboardRows = 4; +constexpr uint8_t kKeyboardCols = 10; +constexpr uint32_t kEpdSpiHz = 2000000; +constexpr uint8_t kFlushLogLimit = 8; +SemaphoreHandle_t g_shared_spi_mutex = nullptr; + +void sharedSpiReleaseAllCs() +{ + digitalWrite(TDeckProBoard::profile().lora.cs, HIGH); + digitalWrite(TDeckProBoard::profile().sd.cs, HIGH); + digitalWrite(TDeckProBoard::profile().epd.cs, HIGH); +} + +void sharedSpiBusInit() +{ + if (g_shared_spi_mutex == nullptr) + { + g_shared_spi_mutex = xSemaphoreCreateRecursiveMutex(); + if (g_shared_spi_mutex == nullptr) + { + Serial.printf("[%s] shared SPI mutex create failed\n", kTag); + return; + } + } + sharedSpiReleaseAllCs(); +} + +void sharedSpiLock() +{ + if (g_shared_spi_mutex == nullptr) + { + sharedSpiBusInit(); + } + if (g_shared_spi_mutex != nullptr) + { + xSemaphoreTakeRecursive(g_shared_spi_mutex, portMAX_DELAY); + } +} + +void sharedSpiUnlock() +{ + if (g_shared_spi_mutex != nullptr) + { + sharedSpiReleaseAllCs(); + xSemaphoreGiveRecursive(g_shared_spi_mutex); + } +} + +void sharedSpiPrepareDevice(int cs_pin) +{ + sharedSpiReleaseAllCs(); + if (cs_pin >= 0) + { + digitalWrite(cs_pin, HIGH); + } +} + +int batteryPercentFromMv(int mv) +{ + if (mv <= 0) + { + return -1; + } + int pct = static_cast(((mv - 3300) / 900.0f) * 100.0f); + if (pct < 0) + { + pct = 0; + } + if (pct > 100) + { + pct = 100; + } + return pct; +} + +bool isLeapYear(int year) +{ + return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0); +} + +uint8_t daysInMonth(int year, uint8_t month) +{ + static constexpr uint8_t kDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month < 1 || month > 12) + { + return 0; + } + if (month == 2 && isLeapYear(year)) + { + return 29; + } + return kDays[month - 1]; +} + +bool gpsDatetimeValid(int year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second) +{ + if (year < 2020 || year > 2100) + { + return false; + } + if (month < 1 || month > 12) + { + return false; + } + const uint8_t max_day = daysInMonth(year, month); + if (day < 1 || day > max_day) + { + return false; + } + return hour < 24 && minute < 60 && second < 60; +} + +int64_t daysFromCivil(int year, unsigned month, unsigned day) +{ + year -= month <= 2; + const int era = (year >= 0 ? year : year - 399) / 400; + const unsigned yoe = static_cast(year - era * 400); + const unsigned doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return static_cast(era) * 146097 + static_cast(doe) - 719468; +} + +time_t gpsDatetimeToEpochUtc(int year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second) +{ + const int64_t days = daysFromCivil(year, month, day); + const int64_t sec_of_day = static_cast(hour) * 3600 + + static_cast(minute) * 60 + + static_cast(second); + const int64_t epoch64 = days * 86400 + sec_of_day; + if (epoch64 < 0 || epoch64 > static_cast(std::numeric_limits::max())) + { + return static_cast(-1); + } + return static_cast(epoch64); +} + +char translateKey(uint8_t idx) +{ + static constexpr char kMap[kKeyboardRows][kKeyboardCols] = { + {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}, + {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'}, + {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\n'}, + {'z', 'x', 'c', 'v', 'b', 'n', 'm', ' ', '\b', '.'}, + }; + const uint8_t row = idx / 10; + const uint8_t col = idx % 10; + if (row >= kKeyboardRows || col >= kKeyboardCols) + { + return '\0'; + } + return kMap[row][col]; +} + +void applyTxPower(SX1262Access& radio, int8_t tx_power) +{ + constexpr int8_t kTxPowerMinDbm = -9; + int8_t clipped = tx_power; + if (clipped < kTxPowerMinDbm) + { + clipped = kTxPowerMinDbm; + } + radio.setOutputPower(clipped); +} +} // namespace + +TDeckProBoard::TDeckProBoard() + : LilyGo_Display(SPI_DRIVER, true) +{ + mono_buffer_.resize(static_cast(profile().screen_width * profile().screen_height) / 8U, 0xFF); +} + +TDeckProBoard* TDeckProBoard::getInstance() +{ + static TDeckProBoard singleton; + return &singleton; +} + +void TDeckProBoard::initSharedPins() +{ + pinMode(profile().lora.enable, OUTPUT); + digitalWrite(profile().lora.enable, HIGH); + + pinMode(profile().gps.enable, OUTPUT); + digitalWrite(profile().gps.enable, HIGH); + + pinMode(profile().motion.enable_1v8, OUTPUT); + digitalWrite(profile().motion.enable_1v8, HIGH); + + if (profile().optional.has_a7682e && profile().optional.a7682e_enable >= 0) + { + pinMode(profile().optional.a7682e_enable, OUTPUT); + digitalWrite(profile().optional.a7682e_enable, HIGH); + } + if (profile().optional.has_a7682e && profile().optional.a7682e_pwrkey >= 0) + { + pinMode(profile().optional.a7682e_pwrkey, OUTPUT); + digitalWrite(profile().optional.a7682e_pwrkey, HIGH); + } + if (profile().motor_pin >= 0) + { + pinMode(profile().motor_pin, OUTPUT); + digitalWrite(profile().motor_pin, LOW); + } + + pinMode(profile().sd.cs, OUTPUT); + digitalWrite(profile().sd.cs, HIGH); + pinMode(profile().epd.cs, OUTPUT); + digitalWrite(profile().epd.cs, HIGH); + pinMode(profile().lora.cs, OUTPUT); + digitalWrite(profile().lora.cs, HIGH); + sharedSpiBusInit(); +} + +bool TDeckProBoard::initPower() +{ + Wire.begin(profile().i2c.sda, profile().i2c.scl); + delay(10); + + power_ready_ = pmu_.begin(Wire, 0x6B, profile().i2c.sda, profile().i2c.scl); + battery_gauge_ready_ = gauge_.begin(Wire, profile().i2c.sda, profile().i2c.scl); + if (power_ready_) + { + pmu_.enableMeasure(); + pmu_.disableOTG(); + } + Serial.printf("[%s] power init charger=%d gauge=%d\n", kTag, power_ready_ ? 1 : 0, battery_gauge_ready_ ? 1 : 0); + return power_ready_ || battery_gauge_ready_; +} + +bool TDeckProBoard::initDisplay() +{ + SPI.begin(profile().spi.sck, profile().spi.miso, profile().spi.mosi, profile().epd.cs); + sharedSpiLock(); + sharedSpiPrepareDevice(profile().epd.cs); + epd_.epd2.selectSPI(SPI, SPISettings(kEpdSpiHz, MSBFIRST, SPI_MODE0)); + epd_.init(0, true, 2, false); + epd_.setRotation(rotation_); + epd_.setFullWindow(); + epd_.firstPage(); + do + { + epd_.fillScreen(GxEPD_WHITE); + } while (epd_.nextPage()); + epd_.powerOff(); + sharedSpiUnlock(); + display_ready_ = true; + Serial.printf("[%s] epd init ok %ux%u spi=%luHz\n", kTag, width(), height(), (unsigned long)kEpdSpiHz); + return true; +} + +bool TDeckProBoard::initTouch() +{ + touch_.setPins(profile().touch.rst, profile().touch.int_pin); + touch_ready_ = touch_.begin(Wire, profile().touch.i2c_addr, profile().i2c.sda, profile().i2c.scl); + if (touch_ready_) + { + touch_.setMaxCoordinates(profile().screen_width, profile().screen_height); + } + Serial.printf("[%s] touch init %s\n", kTag, touch_ready_ ? "ok" : "fail"); + return touch_ready_; +} + +bool TDeckProBoard::initKeyboard() +{ + keyboard_ready_ = keyboard_.begin(profile().keyboard.i2c_addr, &Wire); + if (!keyboard_ready_) + { + Serial.printf("[%s] keyboard init fail\n", kTag); + return false; + } + keyboard_.matrix(profile().keyboard.rows, profile().keyboard.cols); + keyboard_.flush(); + keyboardSetBrightness(keyboard_brightness_); + Serial.printf("[%s] keyboard init ok\n", kTag); + return true; +} + +bool TDeckProBoard::initMotion() +{ + motion_ready_ = motion_.begin(Wire, profile().motion.i2c_addr, profile().i2c.sda, profile().i2c.scl); + Serial.printf("[%s] motion init %s\n", kTag, motion_ready_ ? "ok" : "fail"); + return motion_ready_; +} + +bool TDeckProBoard::initRadio() +{ + SPI.begin(profile().spi.sck, profile().spi.miso, profile().spi.mosi, profile().lora.cs); + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + radio_.reset(); + radio_ready_ = (radio_.begin() == RADIOLIB_ERR_NONE); + sharedSpiUnlock(); + Serial.printf("[%s] radio init %s\n", kTag, radio_ready_ ? "ok" : "fail"); + return radio_ready_; +} + +bool TDeckProBoard::initStorage() +{ + sd_ready_ = installSD(); + Serial.printf("[%s] sd init %s\n", kTag, sd_ready_ ? "ok" : "fail"); + return sd_ready_; +} + +bool TDeckProBoard::installSD() +{ + pinMode(profile().sd.cs, OUTPUT); + digitalWrite(profile().sd.cs, HIGH); + sharedSpiLock(); + sharedSpiPrepareDevice(profile().sd.cs); + const bool ok = SD.begin(profile().sd.cs, SPI); + sharedSpiUnlock(); + return ok; +} + +void TDeckProBoard::uninstallSD() +{ + SD.end(); +} + +uint32_t TDeckProBoard::begin(uint32_t disable_hw_init) +{ + Serial.begin(115200); + delay(30); + Serial.printf("[%s] begin variant=%s\n", kTag, +#if defined(TRAIL_MATE_TDECK_PRO_A7682E) + "a7682e" +#else + "pcm512a" +#endif + ); + + initSharedPins(); + (void)initPower(); + (void)initDisplay(); + + if ((disable_hw_init & NO_HW_TOUCH) == 0) + { + (void)initTouch(); + } + (void)initKeyboard(); + (void)initMotion(); + if ((disable_hw_init & NO_HW_SD) == 0) + { + (void)initStorage(); + } + if ((disable_hw_init & NO_HW_GPS) == 0) + { + (void)initGPS(); + } + (void)initRadio(); + + rtc_ready_ = time(nullptr) >= kMinValidEpochSeconds; + + uint32_t probe = 0; + if (power_ready_) probe |= HW_PMU_ONLINE; + if (battery_gauge_ready_) probe |= HW_GAUGE_ONLINE; + if (touch_ready_) probe |= HW_TOUCH_ONLINE; + if (keyboard_ready_) probe |= HW_KEYBOARD_ONLINE; + if (motion_ready_) probe |= HW_BHI260AP_ONLINE; + if (sd_ready_) probe |= HW_SD_ONLINE; + if (gps_ready_) probe |= HW_GPS_ONLINE; + if (radio_ready_) probe |= HW_RADIO_ONLINE; + return probe; +} + +void TDeckProBoard::setBrightness(uint8_t level) +{ + brightness_ = level; +} + +void TDeckProBoard::keyboardSetBrightness(uint8_t level) +{ + keyboard_brightness_ = level; + if (profile().keyboard.led >= 0) + { + analogWrite(profile().keyboard.led, level); + } +} + +bool TDeckProBoard::isRTCReady() const +{ + return rtc_ready_ || (time(nullptr) >= kMinValidEpochSeconds); +} + +bool TDeckProBoard::isCharging() +{ + return power_ready_ ? pmu_.isCharging() : false; +} + +int TDeckProBoard::getBatteryLevel() +{ + if (battery_gauge_ready_) + { + int level = static_cast(gauge_.getStateOfCharge()); + if (level >= 0 && level <= 100) + { + last_battery_level_ = level; + return level; + } + } + if (power_ready_) + { + int level = batteryPercentFromMv(static_cast(pmu_.getBattVoltage())); + if (level >= 0 && level <= 100) + { + last_battery_level_ = level; + return level; + } + } + return last_battery_level_; +} + +bool TDeckProBoard::isCardReady() +{ + return sd_ready_ && SD.cardType() != CARD_NONE; +} + +void TDeckProBoard::vibrator() +{ + if (profile().motor_pin >= 0) + { + digitalWrite(profile().motor_pin, HIGH); + } +} + +void TDeckProBoard::stopVibrator() +{ + if (profile().motor_pin >= 0) + { + digitalWrite(profile().motor_pin, LOW); + } +} + +void TDeckProBoard::playMessageTone() +{ + if (!profile().optional.has_pcm512a || message_tone_volume_ == 0) + { + return; + } + + static const char kMessageToneRtttl[] = "Msg:d=4,o=6,b=200:32e,32g,32b,16c7"; + AudioOutputI2S audio_out(1, AudioOutputI2S::EXTERNAL_I2S); + audio_out.SetPinout(profile().optional.i2s_bclk, + profile().optional.i2s_lrc, + profile().optional.i2s_dout); + float gain = static_cast(message_tone_volume_) / 250.0f; + if (gain > 0.40f) + { + gain = 0.40f; + } + audio_out.SetGain(gain); + + AudioFileSourcePROGMEM song(kMessageToneRtttl, sizeof(kMessageToneRtttl) - 1); + AudioGeneratorRTTTL generator; + if (generator.begin(&song, &audio_out)) + { + const uint32_t deadline = millis() + 1600; + while (generator.isRunning() && millis() < deadline) + { + if (!generator.loop()) + { + break; + } + delay(1); + } + generator.stop(); + } + audio_out.stop(); +} + +void TDeckProBoard::setMessageToneVolume(uint8_t volume_percent) +{ + if (volume_percent > 100) + { + volume_percent = 100; + } + message_tone_volume_ = volume_percent; +} + +uint8_t TDeckProBoard::getMessageToneVolume() const +{ + return message_tone_volume_; +} + +void TDeckProBoard::setRotation(uint8_t rotation) +{ + rotation_ = rotation & 0x3; + epd_.setRotation(rotation_); +} + +uint16_t TDeckProBoard::width() +{ + return rotation_ % 2 == 0 ? static_cast(profile().screen_width) + : static_cast(profile().screen_height); +} + +uint16_t TDeckProBoard::height() +{ + return rotation_ % 2 == 0 ? static_cast(profile().screen_height) + : static_cast(profile().screen_width); +} + +void TDeckProBoard::setBit(int16_t x, int16_t y, bool black) +{ + if (x < 0 || y < 0 || x >= profile().screen_width || y >= profile().screen_height) + { + return; + } + const size_t idx = static_cast(y) * static_cast(profile().screen_width) + static_cast(x); + const size_t byte_idx = idx / 8U; + const uint8_t bit_mask = static_cast(0x80U >> (idx % 8U)); + if (black) + { + mono_buffer_[byte_idx] &= static_cast(~bit_mask); + } + else + { + mono_buffer_[byte_idx] |= bit_mask; + } +} + +void TDeckProBoard::renderEpd() +{ + if (!display_ready_) + { + return; + } + + sharedSpiLock(); + sharedSpiPrepareDevice(profile().epd.cs); + epd_.setRotation(rotation_); + epd_.setFullWindow(); + epd_.firstPage(); + do + { + epd_.drawInvertedBitmap(0, 0, mono_buffer_.data(), profile().screen_width, profile().screen_height, GxEPD_BLACK); + } while (epd_.nextPage()); + epd_.powerOff(); + sharedSpiUnlock(); +} + +void TDeckProBoard::pushColors(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t* color) +{ + if (!color) + { + return; + } + + static uint8_t s_flush_log_count = 0; + uint32_t dark_pixels = 0; + + for (uint16_t row = 0; row < y2; ++row) + { + for (uint16_t col = 0; col < x2; ++col) + { + const uint16_t pixel = color[static_cast(row) * x2 + col]; + const uint8_t r = static_cast((pixel >> 11) & 0x1F); + const uint8_t g = static_cast((pixel >> 5) & 0x3F); + const uint8_t b = static_cast(pixel & 0x1F); + const uint16_t luminance = static_cast(r * 299U + g * 587U + b * 114U); + const bool black = luminance < 16384U; + if (black) + { + dark_pixels++; + } + setBit(static_cast(x1 + col), static_cast(y1 + row), black); + } + } + if (s_flush_log_count < kFlushLogLimit) + { + Serial.printf("[%s] flush #%u area=(%u,%u %ux%u) dark=%lu/%lu\n", + kTag, + static_cast(s_flush_log_count + 1), + static_cast(x1), + static_cast(y1), + static_cast(x2), + static_cast(y2), + static_cast(dark_pixels), + static_cast(static_cast(x2) * static_cast(y2))); + s_flush_log_count++; + } + renderEpd(); +} + +uint8_t TDeckProBoard::getPoint(int16_t* x, int16_t* y, uint8_t get_point) +{ + if (!touch_ready_) + { + return 0; + } + int16_t tx = 0; + int16_t ty = 0; + const uint8_t touched = touch_.getPoint(&tx, &ty, get_point); + if (!touched || !x || !y) + { + return touched; + } + + switch (rotation_ & 0x3) + { + case 1: + *x = ty; + *y = static_cast(profile().screen_width - 1 - tx); + break; + case 2: + *x = static_cast(profile().screen_width - 1 - tx); + *y = static_cast(profile().screen_height - 1 - ty); + break; + case 3: + *x = static_cast(profile().screen_height - 1 - ty); + *y = tx; + break; + default: + *x = tx; + *y = ty; + break; + } + return touched; +} + +bool TDeckProBoard::keyEventToChar(uint8_t event, char* c, bool* pressed) +{ + if (!c || !pressed) + { + return false; + } + *pressed = (event & 0x80U) != 0; + uint8_t key = static_cast(event & 0x7FU); + if (key == 0) + { + return false; + } + key -= 1; + *c = translateKey(key); + return *c != '\0'; +} + +int TDeckProBoard::getKeyChar(char* c) +{ + if (!keyboard_ready_ || !c || keyboard_.available() == 0) + { + return -1; + } + bool pressed = false; + if (!keyEventToChar(keyboard_.getEvent(), c, &pressed)) + { + return -1; + } + return pressed ? KEYBOARD_PRESSED : KEYBOARD_RELEASED; +} + +int TDeckProBoard::transmitRadio(const uint8_t* data, size_t len) +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const int rc = radio_.transmit(data, len); + sharedSpiUnlock(); + return rc; +} + +int TDeckProBoard::startRadioReceive() +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const int rc = radio_.startReceive(); + sharedSpiUnlock(); + return rc; +} + +uint32_t TDeckProBoard::getRadioIrqFlags() +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const uint32_t flags = radio_.getIrqFlags(); + sharedSpiUnlock(); + return flags; +} + +int TDeckProBoard::getRadioPacketLength(bool update) +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const int len = static_cast(radio_.getPacketLength(update)); + sharedSpiUnlock(); + return len; +} + +int TDeckProBoard::readRadioData(uint8_t* buf, size_t len) +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const int rc = radio_.readData(buf, len); + sharedSpiUnlock(); + return rc; +} + +void TDeckProBoard::clearRadioIrqFlags(uint32_t flags) +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + radio_.clearIrqFlags(flags); + sharedSpiUnlock(); +} + +float TDeckProBoard::getRadioRSSI() +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const float rssi = radio_.getRSSI(); + sharedSpiUnlock(); + return rssi; +} + +float TDeckProBoard::getRadioSNR() +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + const float snr = radio_.getSNR(); + sharedSpiUnlock(); + return snr; +} + +void TDeckProBoard::configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, + int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, + uint8_t crc_len) +{ + sharedSpiLock(); + sharedSpiPrepareDevice(profile().lora.cs); + radio_.setFrequency(freq_mhz); + radio_.setBandwidth(bw_khz); + radio_.setSpreadingFactor(sf); + radio_.setCodingRate(cr_denom); + applyTxPower(radio_, tx_power); + radio_.setPreambleLength(preamble_len); + radio_.setSyncWord(sync_word); + radio_.setCRC(crc_len); + sharedSpiUnlock(); +} + +bool TDeckProBoard::initGPS() +{ + Serial2.begin(profile().gps.uart.baud, SERIAL_8N1, profile().gps.uart.rx, profile().gps.uart.tx); + delay(50); + gps_ready_ = gps_.init(&Serial2); + Serial.printf("[%s] gps init %s\n", kTag, gps_ready_ ? "ok" : "fail"); + return gps_ready_; +} + +void TDeckProBoard::powerControl(PowerCtrlChannel_t ch, bool enable) +{ + switch (ch) + { + case POWER_GPS: + if (profile().gps.enable >= 0) + { + digitalWrite(profile().gps.enable, enable ? HIGH : LOW); + } + break; + case POWER_SENSOR: + if (profile().motion.enable_1v8 >= 0) + { + digitalWrite(profile().motion.enable_1v8, enable ? HIGH : LOW); + } + break; + case POWER_RADIO: + if (profile().lora.enable >= 0) + { + digitalWrite(profile().lora.enable, enable ? HIGH : LOW); + } + break; + default: + break; + } +} + +bool TDeckProBoard::syncTimeFromGPS(uint32_t gps_task_interval_ms) +{ + (void)gps_task_interval_ms; + if (!gps_.date.isValid() || !gps_.time.isValid()) + { + return false; + } + + const int year = gps_.date.year(); + const uint8_t month = gps_.date.month(); + const uint8_t day = gps_.date.day(); + const uint8_t hour = gps_.time.hour(); + const uint8_t minute = gps_.time.minute(); + const uint8_t second = gps_.time.second(); + if (!gpsDatetimeValid(year, month, day, hour, minute, second)) + { + return false; + } + + const time_t epoch = gpsDatetimeToEpochUtc(year, month, day, hour, minute, second); + if (epoch < kMinValidEpochSeconds) + { + return false; + } + + timeval tv{}; + tv.tv_sec = epoch; + tv.tv_usec = 0; + if (settimeofday(&tv, nullptr) != 0) + { + return false; + } + rtc_ready_ = true; + return true; +} + +namespace +{ +TDeckProBoard& getInstanceRef() +{ + return *TDeckProBoard::getInstance(); +} +} // namespace + +TDeckProBoard& instance = getInstanceRef(); +BoardBase& board = getInstanceRef(); + +} // namespace boards::tdeck_pro + +BoardBase& board = ::boards::tdeck_pro::instance; + +#endif // defined(ARDUINO_T_DECK_PRO) 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 ed0156ee..37b5197d 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 @@ -317,6 +317,12 @@ class TLoRaPagerBoard : public BoardBase, void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, uint8_t crc_len) override; + int radioStandby(); + int configureFskRadio(float freq_mhz, float bit_rate_kbps, float freq_dev_khz, float rx_bw_khz, + int8_t tx_power, uint16_t preamble_len, float tcxo_voltage, + const uint8_t* sync_word, size_t sync_word_len, uint8_t crc_len); + int restoreLoRaRadio(); + int startRadioTransmit(const uint8_t* data, size_t len); // GpsBoard void setGPSOnline(bool online) override { setGPSOnlineInternal(online); } @@ -469,6 +475,19 @@ class TLoRaPagerBoard : public BoardBase, #endif private: + struct CachedLoRaConfig + { + bool valid = false; + float freq_mhz = 0.0f; + float bw_khz = 0.0f; + uint8_t sf = 0; + uint8_t cr_denom = 0; + int8_t tx_power = 0; + uint16_t preamble_len = 0; + uint8_t sync_word = 0; + uint8_t crc_len = 0; + }; + // Singleton pattern - prevent copy and assignment TLoRaPagerBoard(); ~TLoRaPagerBoard(); @@ -490,6 +509,7 @@ class TLoRaPagerBoard : public BoardBase, // Two-stage power-off implementation bool isUsbPresent_bestEffort(); + CachedLoRaConfig lora_config_; int power_tier_ = 0; ///< 0=Normal, 1=Low(<=20%), 2=Critical(<=10%) uint32_t devices_probe = 0; ///< Hardware detection status bitmask uint8_t _haptic_effects = 15; ///< Default haptic effect (strong buzz for message notification) diff --git a/boards/tlora_pager/src/tlora_pager_board.cpp b/boards/tlora_pager/src/tlora_pager_board.cpp index 70d62ed4..de08f448 100644 --- a/boards/tlora_pager/src/tlora_pager_board.cpp +++ b/boards/tlora_pager/src/tlora_pager_board.cpp @@ -375,7 +375,8 @@ uint32_t TLoRaPagerBoard::begin(uint32_t disable_hw_init) // Initialize display (ST7796) LilyGoDispArduinoSPI::init(DISP_SCK, DISP_MISO, DISP_MOSI, DISP_CS, DISP_RST, DISP_DC, -1); - log_d("Display (ST7796) initialized: %dx%d", DISP_WIDTH, DISP_HEIGHT); + log_d("Display (ST7796) initialized: logical=%dx%d raw=%dx%d", + LilyGoDispArduinoSPI::_width, LilyGoDispArduinoSPI::_height, DISP_WIDTH, DISP_HEIGHT); // Initialize SPI bus for LoRa/SD/NFC (shared SPI bus) SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI); @@ -675,6 +676,20 @@ bool TLoRaPagerBoard::initNFC() bool TLoRaPagerBoard::initKeyboard() { #ifdef USING_INPUT_DEV_KEYBOARD + if (devices_probe & HW_EXPAND_ONLINE) + { + powerControl(POWER_KEYBOARD, true); + delay(5); +#ifdef EXPANDS_KB_RST + // Reset is held high in the steady state; pulse low to force a clean + // controller restart before probing the TCA8418 over I2C. + io.digitalWrite(EXPANDS_KB_RST, LOW); + delay(5); + io.digitalWrite(EXPANDS_KB_RST, HIGH); + delay(20); +#endif + } + // Configure keyboard backlight pin kb.setPins(KB_BACKLIGHT); @@ -1276,6 +1291,17 @@ int TLoRaPagerBoard::transmitRadio(const uint8_t* data, size_t len) return RADIOLIB_ERR_SPI_WRITE_FAILED; } +int TLoRaPagerBoard::radioStandby() +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + { + int rc = radio_.standby(); + LilyGoDispArduinoSPI::unlock(); + return rc; + } + return RADIOLIB_ERR_SPI_WRITE_FAILED; +} + int TLoRaPagerBoard::startRadioReceive() { if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) @@ -1351,6 +1377,81 @@ float TLoRaPagerBoard::getRadioSNR() return std::numeric_limits::quiet_NaN(); } +int TLoRaPagerBoard::configureFskRadio(float freq_mhz, float bit_rate_kbps, float freq_dev_khz, float rx_bw_khz, + int8_t tx_power, uint16_t preamble_len, float tcxo_voltage, + const uint8_t* sync_word, size_t sync_word_len, uint8_t crc_len) +{ + if (!sync_word || sync_word_len == 0) + { + return -1; + } + + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(200))) + { + int rc = radio_.standby(); + if (rc == RADIOLIB_ERR_NONE) + { + rc = radio_.beginFSK(freq_mhz, bit_rate_kbps, freq_dev_khz, rx_bw_khz, + tx_power, preamble_len, tcxo_voltage); + } + if (rc == RADIOLIB_ERR_NONE) + { + rc = radio_.setSyncWord(const_cast(sync_word), sync_word_len); + } + if (rc == RADIOLIB_ERR_NONE) + { + rc = radio_.setCRC(crc_len); + } + if (rc == RADIOLIB_ERR_NONE) + { + rc = radio_.setPreambleLength(preamble_len); + } + LilyGoDispArduinoSPI::unlock(); + return rc; + } + return RADIOLIB_ERR_SPI_WRITE_FAILED; +} + +int TLoRaPagerBoard::restoreLoRaRadio() +{ + CachedLoRaConfig cached = lora_config_; + + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(200))) + { + int rc = radio_.begin(); + LilyGoDispArduinoSPI::unlock(); + if (rc != RADIOLIB_ERR_NONE) + { + return rc; + } + + if (cached.valid) + { + configureLoraRadio(cached.freq_mhz, + cached.bw_khz, + cached.sf, + cached.cr_denom, + cached.tx_power, + cached.preamble_len, + cached.sync_word, + cached.crc_len); + } + return rc; + } + return RADIOLIB_ERR_SPI_WRITE_FAILED; +} + +int TLoRaPagerBoard::startRadioTransmit(const uint8_t* data, size_t len) +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + { + int rc = radio_.startTransmit(const_cast(data), len); + LilyGoDispArduinoSPI::unlock(); + return rc; + } + return RADIOLIB_ERR_SPI_WRITE_FAILED; +} + #if defined(ARDUINO_LILYGO_LORA_SX1262) static void apply_tx_power(SX1262Access& radio, int8_t tx_power) { @@ -1365,6 +1466,16 @@ void TLoRaPagerBoard::configureLoraRadio(float freq_mhz, float bw_khz, uint8_t s int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, uint8_t crc_len) { + lora_config_.valid = true; + lora_config_.freq_mhz = freq_mhz; + lora_config_.bw_khz = bw_khz; + lora_config_.sf = sf; + lora_config_.cr_denom = cr_denom; + lora_config_.tx_power = tx_power; + lora_config_.preamble_len = preamble_len; + lora_config_.sync_word = sync_word; + lora_config_.crc_len = crc_len; + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(100))) { radio_.setFrequency(freq_mhz); @@ -2526,17 +2637,16 @@ void TLoRaPagerBoard::wakeUp() void TLoRaPagerBoard::enterScreenSleep() { - // Turn off peripheral power to save current; LoRa stays on for mesh. - powerControl(POWER_GPS, false); + // Turn off selected peripheral power to save current; LoRa stays on for mesh. + // GPS power is owned by GpsService/HalGps so screen sleep does not desync + // the hardware rail from the service's gps_powered_/HW_GPS_ONLINE state. powerControl(POWER_NFC, false); - powerControl(POWER_SENSOR, false); } void TLoRaPagerBoard::exitScreenSleep() { - powerControl(POWER_GPS, true); + // Keep GPS power ownership in the GPS service path for consistent state. powerControl(POWER_NFC, true); - powerControl(POWER_SENSOR, true); } void TLoRaPagerBoard::setPowerTier(int tier) diff --git a/docs/MESHTASTIC_PHONE_PROTOCOL_REFERENCE.md b/docs/MESHTASTIC_PHONE_PROTOCOL_REFERENCE.md new file mode 100644 index 00000000..5388833a --- /dev/null +++ b/docs/MESHTASTIC_PHONE_PROTOCOL_REFERENCE.md @@ -0,0 +1,681 @@ +# Meshtastic Phone API / BLE Protocol Reference + +## Purpose + +This document summarizes the Meshtastic phone-facing protocol as implemented by the official upstream code currently vendored in this repo: + +- Firmware: `.tmp/firmware` +- Apple app: `.tmp/Meshtastic-Apple` +- Android app: `.tmp/meshtastic-android` + +The goal is to answer protocol and encoding questions from source-grounded rules instead of relying on local assumptions. + +This document focuses on: + +- `ToRadio` / `FromRadio` +- BLE `fromNum` / `fromRadio` interaction +- `QueueStatus` +- `MeshPacket.id` +- `decoded.request_id` +- `decoded.reply_id` +- `want_ack` +- broadcast vs direct-message behavior +- how official apps interpret ACK/NAK state + +## Source Anchors + +Primary firmware sources: + +- `.tmp/firmware/src/mesh/PhoneAPI.h` +- `.tmp/firmware/src/mesh/PhoneAPI.cpp` +- `.tmp/firmware/src/mesh/api/PacketAPI.cpp` +- `.tmp/firmware/src/mesh/StreamAPI.cpp` +- `.tmp/firmware/src/mesh/Router.cpp` +- `.tmp/firmware/src/mesh/ReliableRouter.cpp` +- `.tmp/firmware/src/mesh/MeshService.cpp` +- `.tmp/firmware/src/mesh/MeshModule.cpp` +- `.tmp/firmware/src/modules/RoutingModule.cpp` +- `.tmp/firmware/src/mesh/generated/meshtastic/mesh.pb.h` + +Official app sources: + +- `.tmp/Meshtastic-Apple/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift` +- `.tmp/Meshtastic-Apple/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift` +- `.tmp/Meshtastic-Apple/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift` +- `.tmp/Meshtastic-Apple/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift` +- `.tmp/Meshtastic-Apple/Meshtastic/Helpers/MeshPackets.swift` +- `.tmp/meshtastic-android/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt` +- `.tmp/meshtastic-android/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt` +- `.tmp/meshtastic-android/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt` +- `.tmp/meshtastic-android/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt` + +## Big Picture + +Meshtastic exposes a "phone API" over multiple transports: + +- BLE +- serial stream +- packet/IPC API +- HTTP variants + +Across those transports, the logical payloads are the same: + +- phone -> device: `ToRadio` +- device -> phone: `FromRadio` + +The transport framing differs, but the application-level meaning does not. + +Important consequence: + +- If we want to know "what the phone is supposed to believe", we must follow `PhoneAPI.cpp` plus the official app code. +- If we want to know "what ACK means", we must follow `Router.cpp`, `ReliableRouter.cpp`, `MeshModule.cpp`, and app-side routing handling. + +## Transport Layer Rules + +### Serial / stream transport + +`StreamAPI.cpp` shows the serial framing: + +- start bytes: `0x94 0xC3` +- then 16-bit big-endian payload length +- then protobuf bytes + +Payload directions: + +- toward device: `ToRadio` +- toward client: `FromRadio` + +This is transport framing only. After decoding, the same `PhoneAPI` logic applies. + +### Packet API transport + +`PacketAPI.cpp` wraps the same behavior in queued protobuf packets. + +Important rules: + +- `ToRadio.packet` is passed into `service->handleToRadio(*mp)` +- `ToRadio.want_config_id` starts the config state machine +- `ToRadio.heartbeat` is handled +- outgoing `FromRadio` packets are produced by `getFromRadio()` + +### BLE transport + +Official BLE behavior is split between: + +- firmware BLE implementation +- `PhoneAPI` +- app BLE client logic + +Upstream firmware exposes: + +- `TORADIO` +- `FROMRADIO` +- `FROMNUM` +- `LOGRADIO` + +Official Apple BLE behavior in `BLEConnection.swift`: + +- phone writes protobuf bytes to `TORADIO` +- phone receives `FROMNUM` notification +- after `FROMNUM`, phone drains pending packets by repeatedly reading `FROMRADIO` +- drain ends when `FROMRADIO` read returns empty payload + +So `FROMNUM` is not the data itself. It is a wakeup/edge signal telling the client that one or more `FromRadio` packets are ready. + +## PhoneAPI State Machine + +`PhoneAPI.cpp` contains the canonical state machine for what the phone receives. + +States are: + +1. `STATE_SEND_NOTHING` +2. `STATE_SEND_UIDATA` +3. `STATE_SEND_MY_INFO` +4. `STATE_SEND_OWN_NODEINFO` +5. `STATE_SEND_METADATA` +6. `STATE_SEND_CHANNELS` +7. `STATE_SEND_CONFIG` +8. `STATE_SEND_MODULECONFIG` +9. `STATE_SEND_OTHER_NODEINFOS` +10. `STATE_SEND_FILEMANIFEST` +11. `STATE_SEND_COMPLETE_ID` +12. `STATE_SEND_PACKETS` + +Important rule explicitly documented in code: + +- client apps assume this config-send order +- upstream comments say: "DO NOT CHANGE IT" + +### Config start + +When the device receives `ToRadio.want_config_id`: + +- `PhoneAPI::handleStartConfig()` is called +- the connection is considered active +- the device enters the config-send sequence +- after config is complete, it sends `FromRadio.config_complete_id` +- only then does it move to `STATE_SEND_PACKETS` + +### Special nonces + +`PhoneAPI.h` defines: + +- `SPECIAL_NONCE_ONLY_CONFIG = 69420` +- `SPECIAL_NONCE_ONLY_NODES = 69421` + +Meaning: + +- `69420`: send config-related state without full node DB walk +- `69421`: focus on node info flow + +Official Apple app uses the same constants in `AccessoryManager.swift`. + +## `ToRadio` Variants + +Source of truth: `PhoneAPI.cpp`, `PacketAPI.cpp`. + +Officially handled variants include: + +- `packet` +- `want_config_id` +- `disconnect` +- `xmodemPacket` +- `mqttClientProxyMessage` +- `heartbeat` + +### `ToRadio.packet` + +This is the normal way for the app to send a mesh packet through the connected device. + +Flow: + +1. phone builds `MeshPacket` +2. wraps in `ToRadio.packet` +3. device `PhoneAPI::handleToRadioPacket()` +4. device applies local rules and rate limits +5. device calls `service->handleToRadio(p)` +6. device injects it into mesh routing via `MeshService` + +### `ToRadio.want_config_id` + +Starts config sync. + +The response is not a single packet. It is the whole config state machine ending with: + +- `FromRadio.config_complete_id = same nonce` + +### `ToRadio.heartbeat` + +In `PhoneAPI.cpp`, heartbeat only sets a flag: + +- `heartbeatReceived = true` + +Then the next `getFromRadio()` emits: + +- `FromRadio.queueStatus` + +So on modern firmware, heartbeat is effectively a "please prove you are alive and tell me queue status" request. + +Official Apple app uses this to detect link liveness. + +## `FromRadio` Variants + +`PhoneAPI.cpp` sends these categories: + +- config-related data: `my_info`, `node_info`, `metadata`, `channel`, `config`, `moduleConfig`, `fileInfo`, `config_complete_id` +- steady-state data: `packet`, `queueStatus`, `mqttClientProxyMessage`, `clientNotification`, `xmodemPacket` +- system events: `rebooted`, `log_record` + +Important distinction: + +- `FromRadio.packet` carries a `MeshPacket` +- `FromRadio.queueStatus` is not a mesh packet +- `FromRadio.clientNotification` is not a mesh packet + +That distinction matters because official apps treat them differently. + +## `MeshPacket` Field Semantics + +Source of truth: `mesh.pb.h`, `Router.cpp`, `MeshModule.cpp`, app code. + +### `MeshPacket.id` + +This is the packet identifier for the mesh packet itself. + +Important upstream comments say: + +- it is unique per sender for a short time window +- used by flooding / ACK / retransmission logic +- used by crypto implementation too + +In firmware: + +- if phone did not set `id`, `MeshService::handleToRadio()` generates one +- queue-status responses use the packet's `id` as `mesh_packet_id` + +Therefore: + +- app-created packet IDs matter +- if app sets `id`, later status signals should correlate back to this same ID + +### `decoded.request_id` + +Upstream protobuf comment: + +- only used in routing or response messages +- indicates the original message ID this message is reporting on + +In practice: + +- routing ACK/NAK packets use `decoded.request_id = original_packet.id` +- normal responses to a request also use `request_id` to point at the request packet +- official apps use `request_id` to correlate a response/ACK with the outbound request + +### `decoded.reply_id` + +Upstream protobuf comment: + +- indicates this message is a reply to a previous message + +This is user/content-level threading, not transport ACK. + +Examples: + +- text reply to a previous message +- emoji reaction targeting a prior message + +Do not confuse `reply_id` with ACK state. + +### `want_ack` + +This means the sender wants reliable delivery behavior and an ACK-style confirmation path. + +However, upstream code makes one critical exception: + +- `Router.cpp` forcibly clears `want_ack` on broadcast packets before they go over LoRa + +That means: + +- broadcast over-the-air packets are never true "normal ACKed unicast sends" +- any phone/app logic that treats broadcast as awaiting a direct recipient ACK is wrong + +## Broadcast vs Direct Message + +This is the most important rule for current debugging. + +### Direct message + +For non-broadcast packets: + +- `want_ack` can remain set +- `ReliableRouter` tracks retransmissions +- recipient may send a true routing ACK/NAK +- official apps can eventually move message to delivered/error based on routing result + +### Broadcast message + +For broadcast packets: + +- `Router.cpp` clears `want_ack` before air transmission +- no normal destination-specific ACK flood is used +- reliability is based on rebroadcast observation and implicit acknowledgment logic + +Upstream `ReliableRouter.cpp` behavior: + +- if the original sender sees someone rebroadcast its broadcast packet +- firmware generates an implicit ACK internally +- this is an optimization for flooding reliability +- that ACK is generated on the original sender node and then surfaces to the phone as a local `ROUTING_APP` result tied to the original `request_id` +- it should not be rewritten as if it came from the rebroadcaster's node identity + +This implicit ACK is not the same thing as: + +- a direct-message ACK from the intended peer +- a conversation-level proof that one specific remote user acknowledged receipt + +Therefore: + +- a broadcast packet must not be surfaced to phone UI as "waiting for DM ACK from peer X" +- a rebroadcaster or relay identity must not be mistaken for the final application peer + +## Official ACK / NAK Generation + +### Routing ACK packet format + +`MeshModule::allocAckNak()` builds a packet with: + +- `decoded.portnum = ROUTING_APP` +- payload = encoded `Routing` +- `decoded.request_id = original packet id` +- `to = original sender` + +This is the canonical ACK/NAK message shape. + +### Reply packet format + +`setReplyTo()` in `MeshModule.cpp` sets: + +- `p->to = original sender` +- `p->channel = original channel` +- `p->want_ack = to.want_ack` except local-phone case +- `p->decoded.request_id = original request id` + +So for admin or other request/response flows: + +- an ordinary response packet can also carry `request_id` +- apps may use that to match request -> response even when it is not a routing ACK + +### ReliableRouter rules + +`ReliableRouter.cpp` distinguishes: + +- ACK: routing packet with `error_reason == NONE`, or non-routing response carrying `request_id` +- NAK: routing packet with non-`NONE` error reason + +Key code: + +- `ackId = ((c && c->error_reason == NONE) || !c) ? p->decoded.request_id : 0` +- `nakId = (c && c->error_reason != NONE) ? p->decoded.request_id : 0` + +Meaning: + +- a packet with `request_id` can stop retransmission +- for routing packets, the error code decides ACK vs NAK + +## `QueueStatus` Semantics + +This is the second most important rule. + +Source of truth: + +- `MeshService::sendToMesh()` +- `MeshService::sendQueueStatusToPhone()` +- `PhoneAPI::handleToRadioPacket()` +- Android `PacketHandler.handleQueueStatus()` + +### What `QueueStatus` means + +After a phone-originated mesh packet is handed into routing, firmware always tries to send a `QueueStatus` back to the phone: + +- `res` = immediate result of enqueue / local send attempt +- `free` = current number of free queue entries +- `maxlen` = queue capacity +- `mesh_packet_id` = the outbound packet ID this status refers to + +This happens in `MeshService::sendToMesh()`. + +So `QueueStatus` answers: + +- was this packet accepted by the local device/radio path? +- what is the local transmit queue state right now? + +It does not answer: + +- whether the remote node received it +- whether the remote node ACKed it +- whether a routing error happened later + +### Heartbeat `QueueStatus` + +Heartbeat also returns a `QueueStatus`, but that one is only link-liveness / local queue information. + +It is not a send result for a specific message unless `mesh_packet_id` points to one. + +### Official Android interpretation + +`PacketHandler.kt`: + +- when packet is sent to radio, status becomes `ENROUTE` +- `handleQueueStatus()` only completes the local "radio accepted it" wait +- if `requestId != 0`, Android matches by `mesh_packet_id` + +So Android uses `QueueStatus` to move past the radio-send stage, not to declare final delivery. + +This is the exact reason "queueStatus arrived" is not enough to clear "waiting to be acknowledged". + +## Official App Send-State Interpretation + +### Android + +Relevant code: + +- `PacketHandler.kt` +- `MeshDataHandler.kt` +- `DataPacket.kt` + +Android status model: + +- `QUEUED` +- `ENROUTE` +- `DELIVERED` +- `ERROR` +- `RECEIVED` + +Behavior: + +1. app sends packet -> `ENROUTE` +2. firmware returns `QueueStatus(mesh_packet_id=id)` -> local send gate completes +3. later, `ROUTING_APP` packet with `request_id=id` drives final status + +`MeshDataHandler.handleRouting()`: + +- decodes `Routing` +- calls `handleAckNak(requestId, fromId, routingError, relayNode)` + +Status mapping: + +- ACK from ultimate target or reaction target may become `RECEIVED` +- ACK otherwise becomes `DELIVERED` +- non-zero routing error becomes `ERROR` + +The essential point: + +- final delivery status comes from `ROUTING_APP`, not `QueueStatus` + +### Apple + +Relevant code: + +- `AccessoryManager.swift` +- `MeshPackets.swift` + +Apple receives `FromRadio.packet`, checks `decoded.portnum`, and for `ROUTING_APP` calls: + +- `MeshPackets.shared.routingPacket(packet:connectedNodeNum:)` + +That handler: + +- finds message by `packet.decoded.requestID` +- stores `ackError` +- if `routingMessage.errorReason == .none`, sets `receivedACK = true` +- records `relayNode`, `ackTimestamp`, `ackSNR` + +Apple therefore also treats: + +- routing packet keyed by `requestID` +- as the authoritative ACK/NAK path + +Again: + +- `QueueStatus` is not final delivery + +## BLE Read / Notify Contract + +Combining firmware and Apple app: + +1. phone writes `ToRadio` to `TORADIO` +2. firmware eventually increments `fromNum` +3. firmware notifies `FROMNUM` +4. app starts draining `FROMRADIO` +5. app keeps reading until empty read + +Important consequences: + +- if firmware queues `FromRadio` data but does not cause the app to drain, status updates can appear delayed +- if firmware only wakes the app for some variants and not others, phone-side state may lag +- if `QueueStatus` or `ROUTING_APP` packets are generated but not drained, UI stays stale + +## What Must Not Be Misinterpreted + +### Rule 1: `QueueStatus` is not final ACK + +Incorrect: + +- "I saw `QueueStatus` for packet `X`, so message `X` was acknowledged by the peer" + +Correct: + +- "`QueueStatus` means the local radio path accepted or rejected the outbound packet" + +### Rule 2: `reply_id` is not ACK state + +Incorrect: + +- "This packet has `reply_id`, so it acknowledges the earlier packet" + +Correct: + +- `reply_id` is conversation-level reply threading + +### Rule 3: broadcast should not be modeled as direct-message ACK + +Incorrect: + +- "Broadcast packet to `0xFFFFFFFF` should wait for recipient ACK" + +Correct: + +- broadcast ACK-on-air is suppressed by upstream router +- reliability uses flooding / rebroadcast observation +- relay observations are not direct recipient ACK semantics + +### Rule 4: relay / rebroadcast node is not automatically the logical sender of ACK + +Incorrect: + +- "I saw a routing-related event from short relay `0x11`; therefore node `0x11` is the chat peer who acknowledged" + +Correct: + +- it may be an intermediate relay, rebroadcaster, or broadcast-side routing artifact +- interpretation depends on whether the original packet was unicast or broadcast + +## Concrete Rules For Our Integration + +These rules follow upstream behavior and should be treated as protocol constraints. + +### Outbound phone message + +- Preserve `MeshPacket.id` if app/core assigned one. +- Use that same ID as the stable correlation key across: + - `QueueStatus.mesh_packet_id` + - `ROUTING_APP.decoded.request_id` + - any response packet carrying `decoded.request_id` + +### Broadcast text send + +- Do not model broadcast text as requiring a direct recipient ACK. +- Do not convert relay or rebroadcast observations into peer-delivery ACK for chat UI. +- Do not surface a broadcast routing artifact as if it were a DM acknowledgment from a user node. + +### Direct-message text send + +- `QueueStatus` means local acceptance only. +- Wait for `ROUTING_APP` or a request-correlated response to decide final state. +- A `Routing.Error.NONE` for matching `request_id` is the canonical success signal. + +### Admin / request-response flows + +- Some requests may be effectively confirmed by a real response packet carrying `request_id`. +- Official Apple app explicitly treats admin responses as an ACK-equivalent for the admin log entry. + +### BLE transport + +- `FROMNUM` must wake draining of `FROMRADIO`. +- All generated `FromRadio` packets that matter to UI state must be drainable in a timely way. + +## Why `from=00000011` Was Suspicious In Our Case + +From upstream rules alone: + +- if the original outbound packet was a broadcast message +- and the phone/UI later treated a routing-related event from `0x00000011` as the final peer ACK +- that interpretation is wrong + +Because upstream says: + +- broadcasts do not carry normal over-air `want_ack` +- their reliability path is based on flooding and implicit observation +- relay/rebroadcast evidence is not equivalent to DM recipient acknowledgment + +So if a broadcast text on our branch ended up surfacing: + +- `request_id = original text id` +- plus a routing-style success attributed to a relay-like node + +the likely bug is not "Meshtastic protocol says relay `0x11` is the peer ACK sender". + +The likely bug is: + +- our integration mapped a broadcast-side routing observation into a DM-style ACK event for the phone layer + +That conclusion is source-consistent with upstream behavior. + +## Practical Debug Checklist + +When debugging a message stuck on "waiting to be acknowledged", check in this order: + +1. Did the phone send `ToRadio.packet` with a stable `MeshPacket.id`? +2. Did firmware emit `QueueStatus.mesh_packet_id == that id`? +3. If no, the problem is local enqueue / transport / BLE drain. +4. If yes, did a later `FromRadio.packet` arrive with `decoded.request_id == that id`? +5. If yes and `portnum == ROUTING_APP`, decode `Routing.error_reason`. +6. If the original packet was broadcast, do not interpret relay observations as DM ACK. +7. If the original packet was a request expecting content response, also check non-routing response packets carrying `request_id`. + +## Short Reference Table + +`MeshPacket.id` + +- ID of the outbound packet itself +- primary correlation key + +`QueueStatus.mesh_packet_id` + +- local enqueue/send result for outbound packet ID +- not final remote ACK + +`decoded.request_id` + +- "this packet refers to original packet ID X" +- used for routing ACK/NAK and normal responses + +`decoded.reply_id` + +- content/thread reply to previous message +- not transport ACK + +`want_ack` + +- reliable-delivery request for unicast path +- cleared by router for broadcast over the air + +`ROUTING_APP` + +- canonical ACK/NAK packet family +- official apps use it for final delivery state + +## Notes For Future Maintenance + +If upstream changes behavior, re-check at least: + +- `PhoneAPI.cpp` +- `Router.cpp` +- `ReliableRouter.cpp` +- `MeshService.cpp` +- Apple `BLEConnection.swift` +- Apple `MeshPackets.swift` +- Android `PacketHandler.kt` +- Android `MeshDataHandler.kt` + +If we change our local adapter behavior, we should compare against this document first, then update the implementation, not the rules. diff --git a/docs/UI_SHARED_IDF_PHASE_PLAN.md b/docs/UI_SHARED_IDF_PHASE_PLAN.md index 580cde43..adadfc7a 100644 --- a/docs/UI_SHARED_IDF_PHASE_PLAN.md +++ b/docs/UI_SHARED_IDF_PHASE_PLAN.md @@ -1,84 +1,70 @@ -# UI Shared / IDF ??????? +# UI Shared / IDF 迁移计划 -?????? UI ?????`apps/*` ??????? ESP-IDF ???????????? +这份文档用于说明:如何把 UI 逻辑从各个 `apps/*` 中抽离出来,沉淀到可复用的 shared 层,并同时兼容 PlatformIO 与 ESP-IDF 两套入口。 -??????? +--- -- ???????????????????????? -- ???? `apps/esp_pio`?`apps/esp_idf`?`modules/ui_shared` ????????????? -- PlatformIO ? ESP-IDF ??????? shared UI/page/runtime ???? +## 1. 目标 -## 1. ??? +### 1.1 总目标 -?????????????????????????????? +1. 把通用 UI 页面、shell、controller、runtime 接口集中到 `modules/ui_shared` +2. 让 `apps/esp_pio` 与 `apps/esp_idf` 只保留各自平台入口、启动流程和适配器 +3. 避免 shared UI 直接依赖 Arduino 或 ESP-IDF 具体实现细节 -1. **??????**???????????????? `modules/ui_shared` -2. **?????**?`apps/esp_pio` ? `apps/esp_idf` ??? startup / loop / runtime / adapter ???? -3. **??????**??? PIO / IDF ????? `platform/*` ? app runtime adapter ???????????? +### 1.2 迁移原则 -## 2. ???? +- 页面结构、交互和状态逻辑优先沉淀到 shared 层 +- 平台差异收敛到 `platform/*` 和 app runtime adapter +- 同一个页面不要在 PIO / IDF 各维护一份实现 +- 对暂时无法共享的能力,用 capability-gated fallback 占位 -### 2.1 ?????? +--- -??????????????? +## 2. 分层边界 -- ??????????? `modules/ui_shared` -- `*_page_shell.cpp` ???????host ???fallback ?? -- ???? shell ?????????????? -- ???? `apps/esp_pio` ? `apps/esp_idf` ????????? +### 2.1 `modules/ui_shared` -### 2.2 apps ????????? +承载: -`apps/esp_pio` ? `apps/esp_idf` ?????? +- page shell +- 页面组件 +- controller / presenter +- runtime 抽象接口 +- 通用 fallback 页面 -- ????startup / boot / menu ???? -- loop ??????tick??? -- runtime access?????????????????? -- facade/runtime adapter???????? shared ??? shared ????? +不应直接依赖: -?????? +- `` +- `` +- `nvs.h` +- 具体板级 API -- ???? -- ???? -- ??????? -- ?????? -- ???? wrapper ???? +### 2.2 `apps/esp_pio` / `apps/esp_idf` -### 2.3 ???? placeholder ?????? +承载: -?????????????????? shared ??????????????????? +- startup / boot +- menu / app catalog 入口 +- loop 驱动 +- 生命周期管理 +- facade / runtime adapter 装配 -placeholder ?????? +### 2.3 `platform/*` -- ????????????? -- ?????????????????????? -- ??????????? runtime/capability ?????? +承载: -placeholder ??????????????? +- 设备能力实现 +- 屏幕 / 睡眠 / 音频 / GPS / 存储等平台 API +- Arduino 与 IDF 各自的 contract 实现 -### 2.4 PIO / IDF ?????? +--- -????????????????????? +## 3. 当前状态(截至 2026-03-11) -- `apps/esp_pio` ???? -- `apps/esp_idf` ???? -- ??????????? shared ?? -- ?? runtime adapter ? PIO / IDF ??????????? capability gating +### 3.1 已经共享的页面骨架 -### 2.5 ????????? profile ?? - -???????? profile / runtime config ??????????????????????? - -???? - -- `tab5` ??????? `tdeck` ?????????? `pager` -- topbar???? icon card???/???????? shared profile ???? - -## 3. ???????2026-03-11? - -### 3.1 ??? shared shell ?????? - -????????????????? shared `shell + runtime/components/controller` ??? +已经迁到 shared `shell + runtime/components/controller` 的页面: - `Settings` - `Chat` @@ -93,304 +79,153 @@ placeholder ??????????????? - `Energy Sweep` - `Walkie Talkie` -??? +### 3.2 apps 侧现状 -- ?????????????? shared ???? -- ???????????????????? shared capability-gated fallback ?? - -### 3.2 apps ????? - -?? `apps/esp_pio/src` ???????????????? +`apps/esp_pio/src` 和 `apps/esp_idf/src` 已经逐步收敛到以下职责: - `startup_runtime.cpp` - `loop_runtime.cpp` - `app_runtime_access.cpp` - `app_registry.cpp` -- `arduino_entry.cpp` -- `app_context.cpp` -?? `apps/esp_idf/src` ????????????????? +其中 `esp_idf` 还包含: -- `startup_runtime.cpp` -- `loop_runtime.cpp` -- `app_runtime_access.cpp` -- `app_registry.cpp` - `runtime_config.cpp` - `app_facade_runtime.cpp` - `idf_entry.cpp` - `idf_component_anchor.cpp` -????? +### 3.3 已完成的清理 -- ?? 5 ??? IDF retired stub ????? -- `modules/ui_shared` ??? `ui_common_stub.cpp` / `ui_status_stub.cpp` ????? 6 ?? -- `apps/esp_pio/src` ?? `ui_*.cpp` ?? wrapper ?? - -### 3.3 ???????? - -- **????? shared ????**?`Settings`?`Chat`?`Contacts`?`Team` -- **??? shared shell / runtime ?????????????? 4 ????**?`GPS / Map`?`GNSS Sky Plot`?`Tracker`?`PC Link / USB`?`SSTV`?`Energy Sweep`?`Walkie Talkie` -- **??? shared shell ????**??? capability-gated ?????? `ui/screens/common/page_shell_fallback.h` - -## 4. ??????? +- 一批旧的 IDF retired stub 已移除 +- `modules/ui_shared` 里的 `ui_common_stub.cpp` / `ui_status_stub.cpp` 已显著收缩 +- `apps/esp_pio/src` 下保留的 `ui_*.cpp` 多数只剩 wrapper 职责 --- -## ?? 0?????????? +## 4. 分阶段计划 -### ?? +## 阶段 0:盘点与止血 -???????????????????????????? / adapter / stub ??? +### 目标 -### ???? +- 先明确哪些页面已经 shared,哪些还残留 app 私有实现 +- 停止新增重复页面实现 -- ???????????? -- ???? app ????? -- ??????? shared shell ?????? +### 交付 -### ???? - -- **???** +- 页面归属清单 +- app 私有 wrapper 清单 +- shared shell 缺口清单 --- -## ?? 1?apps ??????? +## 阶段 1:apps 入口收敛 -### ?? +### 目标 -? `apps/esp_pio` ? `apps/esp_idf` ???????? + runtime ????????? wrapper ?????? +把 `apps/esp_pio` 和 `apps/esp_idf` 收敛成“启动 + loop + runtime 接线”。 -### ??? +### 任务 -1. ?? `app_catalog` / `menu` / `startup` / `loop` ??? shared ?? -2. ?? `apps/esp_pio` ???? page wrapper / registry ???? -3. ?? `app_runtime_access` ??????????????????? -4. ?? `esp_pio` / `esp_idf` ? startup / loop / event ???? +1. 把 `app_catalog` / `menu` / `startup` / `loop` 对齐到 shared 模式 +2. 清理 `apps/esp_pio` 中历史页面 wrapper / registry 特例 +3. 收敛 `app_runtime_access` 的生命周期与运行时访问 +4. 统一 `esp_pio` / `esp_idf` 的 startup / loop / event 驱动模式 -### ???? +### 完成标准 -- `apps/esp_pio` / `apps/esp_idf` ??????????? -- `app_runtime_access` ?? lifecycle / runtime access -- ????????? shared app catalog / shared shell ???? - -### ???? - -- **????2026-03-11?** -- `apps/esp_pio/src` ? `apps/esp_idf/src` ?????????? -- `apps/esp_pio/src` ?? `ui_*.cpp` ?? wrapper ?? -- startup / loop / menu / runtime access ??? shared shell ???? +- 两端 app 目录不再承载具体 UI 逻辑 +- app 入口能稳定驱动 shared app catalog / shared shell --- -## ?? 2??????????? +## 阶段 2:页面共享完成 -### ?? +### 目标 -???????????? +把页面层真正统一到 shared: -- `shell`???? / host / fallback -- `components` / `controller` / `runtime`????? shared ?? +- shell +- host +- fallback +- components / controller / runtime -### ??? +### 任务 -1. ?????? `shell + components/runtime` ?? -2. ?? shell ??????????? -3. ????????? shared app catalog / shared page shell ?? shared ?? -4. ?? fallback ????????? runtime ??????? +1. 补齐 shared `shell + components/runtime` +2. 移除 app 侧残留页面逻辑 +3. 统一 app catalog 与 shared page shell 的接线 +4. 对缺失能力使用 capability-gated fallback -### ???? +### 完成标准 -- ????????????? -- `shell` ?????????? -- ????????????????????? - -### ???? - -- **????2026-03-11?** -- `Chat / Contacts / Team / Settings` ? shared shell ??? placeholder fallback ?????????? runtime ?? -- ?? capability-gated ?????? `page_shell_fallback.h`?????????? enter / exit placeholder ?? -- apps ??????????????? +- 页面结构只在 `modules/ui_shared` 维护 +- fallback 行为一致 +- app 侧不再复制页面实现 --- -## ?? 3????????? +## 阶段 3:平台能力抽象完成 -### ?? +### 目标 -? shared ????????????? adapter / runtime hook ?? +把 shared UI 依赖的设备能力全部收敛到 adapter contract。 -### ??? +### 需要抽象的能力 -1. ? shared UI ???? adapter contract?????? - - restart - - kv/config persistence - - screen sleep - - tone/audio preview - - GPS runtime control - - tracker recording runtime hook - - hostlink / USB capability hook -2. Arduino ??? `platform/esp/arduino_common` -3. IDF ??? `platform/esp/idf_common` ? `apps/esp_idf/*runtime` -4. shared ????? contract?????? ESP ??? +- restart +- kv / config persistence +- screen sleep +- tone / audio preview +- GPS runtime control +- tracker recording hook +- hostlink / USB capability hook +- walkie / sstv / lora support -### ???? +### 落点 -`modules/ui_shared` ???????????????? +- Arduino 实现在 `platform/esp/arduino_common` +- IDF 实现在 `platform/esp/idf_common` 与 `apps/esp_idf/*runtime` -- `` -- `` -- `nvs.h` -- `esp_system.h` -- ?? board ???? +### 完成标准 -### ???? - -- **????2026-03-10?** -- `modules/ui_shared` ????????? ESP ???? `` -- ??? `platform/ui/*` contract??? device / screen / settings / gps / tracker / hostlink / walkie / sstv / lora / usb_support -- Arduino ??? `platform/esp/arduino_common`?IDF ??? `platform/esp/idf_common` +- `modules/ui_shared` 不再直接包含 ESP 专有头文件 +- shared 只依赖平台 contract --- -## ?? 4??????????? +## 阶段 4:配置与 profile 收敛 -### ?? +### 目标 -??????? capability fallback ????????????????????? shared ????? +把设备 profile、视觉尺寸、能力差异统一到 runtime config / page profile。 -### ????? +### 重点 -1. `Settings` ?? adapter ?????? -2. `Chat` -3. `GPS / Map` -4. `GNSS Sky Plot` -5. `Contacts / Team / Node Info` -6. `Tracker` -7. `PC Link / USB` -8. `SSTV / Energy Sweep / Walkie Talkie` +- `tab5` +- `tdeck` +- `pager` -### ?????? +### 要求 -?????????????????? - -- shared ???????????? placeholder -- PIO / IDF ?????? -- ???? shared ????? -- ???? runtime / facade adapter ??? -- ????? `tab5` ? `tdeck` ???? profile ?? - -### ???? - -- **????2026-03-11?** -- `Settings`?`Chat`?`Contacts`?`Team` ??? shared ??????? -- `GPS / Map`?`GNSS Sky Plot`?`Tracker`?`PC Link / USB`?`SSTV`?`Energy Sweep`?`Walkie Talkie` ???? 4 ?????????? -- ?? 6 ?????????????????? 4 ????????? +- topbar、高度、间距、图标卡片等由 shared profile 驱动 +- 板级差异不散落在页面代码里 --- -## ?? 5?IDF ??????? +## 5. 风险点 -### ?? - -? `apps/esp_idf` ????????? shared ??????????????????? - -### ??? - -1. ????? stub????? IDF runtime / facade -2. ? IDF runtime facade ? shared ?????? -3. ?? `tab5` profile / topbar / menu layout / page layout ?? -4. ?? capability-based fallback??????????? - -### ???? - -- `tab5` ????? startup shell ???? -- ?????????????? -- ???????????? stub ???? -- ????? capability / adapter ?????????????? - -### ???? - -- **????2026-03-10?** -- `apps/esp_idf` ??????? / ?? / ????????????? skeleton ??? -- IDF ????? capability gating ??? `Chat / Contacts / Team / Settings` ????? shared runtime ???? -- `screen_sleep`?`ui_common`?`gps_service_api`?`gps_tracker_overlay`?`team_ui_store` ???? IDF ?? -- `tab5` ??????? `modules/ui_shared/src/ui/startup_shell.cpp`??? `menu_runtime.cpp` / `ui_status.cpp` ????????? +- shared 页面已经完成结构迁移,但 runtime hook 仍可能带有平台耦合 +- PIO / IDF 两套入口的生命周期节奏不完全一致,容易出现事件顺序差异 +- fallback 如果设计过弱,短期内会掩盖真实缺口 +- profile 还未彻底统一前,不同设备上可能继续出现布局分叉 --- -## ?? 6????????? +## 6. 验收标准 -### ?? - -??????????????????????? - -### ??? - -1. ??????? wrapper / shim / duplicate shell logic -2. ???? stub ?????? -3. ?? README / ???? / ???? -4. ???????? - -### ???? - -- ?????? `modules/ui_shared` -- app ??????? runtime ?? -- platform ?????????? -- ????????? - -### ???? - -- **????2026-03-11?** -- ??? `apps/esp_idf/src/*_stub.cpp` ? `modules/ui_shared/src/ui/*_stub.cpp` ?? retired ???? -- `idf_component_stub.cpp` ????? `apps/esp_idf/src/idf_component_anchor.cpp`??????? stub????????? -- `TeamUiStoreStub` ???? `TeamUiMemoryStore`????????????????????? -- `Chat / Contacts / Team / Settings` ? shared page shell ??? placeholder fallback ?????????? runtime ?? -- ?? capability-gated ??????? `ui/screens/common/page_shell_fallback.h` -- `apps/esp_pio/src` ?? `ui_*.cpp` ?? wrapper ???`apps/esp_idf/src` ?????????? -- ?? README?????? build ????????? - -## 5. ?????? - -### ???? - -2026-03-11 ???? - -- `platformio run -e tdeck` -- `idf.py -B build.tab5 -DTRAIL_MATE_IDF_TARGET=tab5 build` - -### ???? - -2026-03-11 ???? - -- `apps/esp_pio/src` ?? `ui_*.cpp` ?? wrapper ?? -- `apps/esp_idf/src` ??????????? retired `*_stub.cpp` ?? -- `modules/ui_shared` ? capability fallback ?????? helper?????? shell ???? placeholder ?? -- shared ?????????? `modules/ui_shared` - -### ?????? - -?????????? - -- `tab5` ?? shell?????????????? 5 ??????????? -- ???? 6 ??????????helper ???retired ?????????? runtime ?? - -## 6. ?????? - -?? 2026-03-11? - -- **?? 0?1?2?3?5?6 ???** -- **?? 4 ?????**?????????????????????????? - -????? - -- ????? UI / app / platform ?????? -- ????????????????????????? 4 ??????????? - -### 2026-03-11 阶段 4 完成更新 - -- 阶段 4 现已完成。 -- `pc_link`、`sstv`、`energy_sweep`、`walkie_talkie` 的 shared page runtime 现已统一支持 Arduino / ESP-IDF(`ESP_PLATFORM`) 编译,并通过 `platform::ui::*` 能力接口决定是否可用。 -- `modules/ui_shared/src/ui/app_catalog_builder.cpp` 不再把 `Walkie Talkie` 入口硬编码在 Pager 专属宏后面,菜单入口统一由 shared feature flags 控制。 -- `apps/esp_pio/src/app_registry.cpp` 已切换到与 `apps/esp_idf/src/app_registry.cpp` 一致的 `platform::ui::*` 能力判定,不再在 apps 层维护独立宏分叉。 -- `USB` 在 IDF 侧当前仍属于 shared shell + capability gate 的特例;真正的 IDF USB / Hostlink / LoRa / Walkie 平台后端对接继续归入阶段 5。 -- 验证已通过:`idf.py -B build.tab5 -DTRAIL_MATE_IDF_TARGET=tab5 build`、`platformio run -e tdeck`。 \ No newline at end of file +- `modules/ui_shared` 成为页面 UI 的唯一事实源 +- `apps/esp_pio` 与 `apps/esp_idf` 主要负责启动和装配 +- 平台差异只出现在 `platform/*` 与 runtime adapter +- 新页面默认先落到 shared,而不是 app 私有目录 diff --git a/docs/devices/gat562-mesh-evb-pro.md b/docs/devices/gat562-mesh-evb-pro.md index f6903576..addb0355 100644 --- a/docs/devices/gat562-mesh-evb-pro.md +++ b/docs/devices/gat562-mesh-evb-pro.md @@ -33,7 +33,7 @@ The most important verified difference at the time of writing: - `GAT562 Mesh EVB Pro` should be treated as not having on-board external QSPI flash - the old inherited `PIN_QSPI_*` / `EXTERNAL_FLASH_*` definitions were removed from - [variant.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/gat562_mesh_evb_pro/variant.h) + [variant.h](../../variants/gat562_mesh_evb_pro/variant.h) When adapting this board further, prefer verified EVB Pro hardware facts over reference-variant inheritance. @@ -42,12 +42,12 @@ reference-variant inheritance. Primary board definition files: -- [boards/gat562_mesh_evb_pro.json](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/gat562_mesh_evb_pro.json) -- [board_profile.h](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h) -- [gat562_board.h](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h) -- [gat562_board.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/gat562_mesh_evb_pro/src/gat562_board.cpp) -- [variant.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/gat562_mesh_evb_pro/variant.h) -- [gat562_mesh_evb_pro.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini) +- [boards/gat562_mesh_evb_pro.json](../../boards/gat562_mesh_evb_pro.json) +- [board_profile.h](../../boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h) +- [gat562_board.h](../../boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h) +- [gat562_board.cpp](../../boards/gat562_mesh_evb_pro/src/gat562_board.cpp) +- [variant.h](../../variants/gat562_mesh_evb_pro/variant.h) +- [gat562_mesh_evb_pro.ini](../../variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini) Rules: @@ -66,7 +66,7 @@ The currently verified EVB Pro pin map in this repository is: ### Buttons And Joystick -Defined in [board_profile.h](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h). +Defined in [board_profile.h](../../boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/board_profile.h). - Primary button: `9` - Secondary button: `12` @@ -125,7 +125,7 @@ For that reason the environment enables: See: -- [gat562_mesh_evb_pro.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini) +- [gat562_mesh_evb_pro.ini](../../variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini) This is required so `P0.09` / `P0.10` behave as normal GPIOs instead of NFC pins. @@ -133,8 +133,8 @@ This is required so `P0.09` / `P0.10` behave as normal GPIOs instead of NFC pins Useful debug path during bring-up: -- board raw input logs are emitted from [gat562_board.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/gat562_mesh_evb_pro/src/gat562_board.cpp) -- UI input logs are emitted from [ui_runtime.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp) +- board raw input logs are emitted from [gat562_board.cpp](../../boards/gat562_mesh_evb_pro/src/gat562_board.cpp) +- UI input logs are emitted from [ui_runtime.cpp](../../apps/gat562_mesh_evb_pro/src/ui_runtime.cpp) Typical healthy joystick logs look like: diff --git a/docs/devices/lilygo-t-lora-pager.md b/docs/devices/lilygo-t-lora-pager.md index 7d2022a4..e309e887 100644 --- a/docs/devices/lilygo-t-lora-pager.md +++ b/docs/devices/lilygo-t-lora-pager.md @@ -64,11 +64,11 @@ working with real hardware: Primary board definition files: -- [boards/lilygo-t-lora-pager.json](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/lilygo-t-lora-pager.json) -- [pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h) -- [tlora_pager.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/envs/tlora_pager.ini) -- [tlora_pager_board.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/tlora_pager/src/tlora_pager_board.cpp) -- [tlora_pager_board.h](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h) +- [boards/lilygo-t-lora-pager.json](../../boards/lilygo-t-lora-pager.json) +- [pins_arduino.h](../../variants/lilygo_tlora_pager/pins_arduino.h) +- [tlora_pager.ini](../../variants/lilygo_tlora_pager/envs/tlora_pager.ini) +- [tlora_pager_board.cpp](../../boards/tlora_pager/src/tlora_pager_board.cpp) +- [tlora_pager_board.h](../../boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h) Rules: @@ -80,7 +80,7 @@ Rules: ## Important Boundary This repository uses the LilyGo Pager as an ESP board with its own runtime -implementation in [tlora_pager_board.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/tlora_pager/src/tlora_pager_board.cpp). +implementation in [tlora_pager_board.cpp](../../boards/tlora_pager/src/tlora_pager_board.cpp). That means the most authoritative sources for day-to-day maintenance are: @@ -93,7 +93,7 @@ board runtime unless real hardware verification proves otherwise. ## Build Environments -Defined in [tlora_pager.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/envs/tlora_pager.ini): +Defined in [tlora_pager.ini](../../variants/lilygo_tlora_pager/envs/tlora_pager.ini): - `tlora_pager_sx1262` - `tlora_pager_sx1262_debug` @@ -111,7 +111,7 @@ Current build-time facts: ## Verified Pin Map The pin map below is taken from -[pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h), +[pins_arduino.h](../../variants/lilygo_tlora_pager/pins_arduino.h), which is the active variant source for this repo. ### Shared I2C Bus @@ -217,7 +217,7 @@ Notes: The Pager uses an `XL9555` I/O expander to gate multiple peripherals. Current logical assignments from -[pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h): +[pins_arduino.h](../../variants/lilygo_tlora_pager/pins_arduino.h): - `EXPANDS_DRV_EN = 0` - `EXPANDS_AMP_EN = 1` @@ -255,7 +255,7 @@ These flags are part of the board contract and are relied on by the ESP platform ## Runtime Bring-Up Notes The board runtime in -[tlora_pager_board.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/tlora_pager/src/tlora_pager_board.cpp) +[tlora_pager_board.cpp](../../boards/tlora_pager/src/tlora_pager_board.cpp) currently initializes or manages: - battery gauge `BQ27220` @@ -274,6 +274,18 @@ When debugging missing peripherals on Pager, always check both: 1. the raw pin assignment 2. the relevant `XL9555` enable line or runtime init path +## Sleep / Power Ownership Notes + +- GPS power is owned by `HalGps` / `GpsService`, not by screen sleep helpers. +- NFC power is board-owned and is toggled by Pager board runtime entrypoints such as + `initNFC()`, `startNFCDiscovery()`, `stopNFCDiscovery()`, and screen sleep. +- The motion sensor is initialized by the board runtime, but Pager does not currently + implement a dedicated `POWER_SENSOR` hardware branch in `powerControl()`. Do not + assume screen sleep physically power-cycles `BHI260AP`. +- Walkie temporarily switches the radio into FSK mode. Pager now caches the last + normal LoRa config so the board can restore the previous LoRa parameters when + Walkie exits before the higher-level mesh config path re-applies its own runtime state. + ## Display Notes There are two dimensions worth remembering: @@ -286,20 +298,19 @@ validated in runtime code rather than assumed from the raw panel numbers alone. ## Known Risks / Maintenance Notes -- `boards/lilygo-t-lora-pager.json` currently points at variant `lilygo_twatch_ultra` - while the actual pin definitions used for Pager live under - [variants/lilygo_tlora_pager](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager). - This is worth treating carefully whenever board configuration is refactored. - Pager hardware is highly multiplexed. A peripheral can fail because of shared-bus contention, expander power state, or init order, not just because a GPIO number is wrong. - LoRa, SD, NFC and display all share the SPI bus, so bus ownership issues are realistic. - Many auxiliary devices share the same I2C bus, so probe order and bus locking matter. +- Some apparent power-control paths are logical rather than physical. Maintenance changes + should avoid assuming every peripheral named in sleep or init code has a real board-level + power gate unless `powerControl()` actually implements it. ## Maintenance Guidance When changing this board next time: -1. Update [pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h) first for GPIO truth. -2. Update [tlora_pager.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/envs/tlora_pager.ini) if the radio or build flags change. -3. Update [tlora_pager_board.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/tlora_pager/src/tlora_pager_board.cpp) for init order, power gating or runtime behavior. +1. Update [pins_arduino.h](../../variants/lilygo_tlora_pager/pins_arduino.h) first for GPIO truth. +2. Update [tlora_pager.ini](../../variants/lilygo_tlora_pager/envs/tlora_pager.ini) if the radio or build flags change. +3. Update [tlora_pager_board.cpp](../../boards/tlora_pager/src/tlora_pager_board.cpp) for init order, power gating or runtime behavior. 4. Keep this document aligned with the checked-in implementation, not with stale vendor copy. diff --git a/docs/meshtastic_ble_timing.md b/docs/meshtastic_ble_timing.md new file mode 100644 index 00000000..ef3d59e6 --- /dev/null +++ b/docs/meshtastic_ble_timing.md @@ -0,0 +1,349 @@ +# Meshtastic BLE 交互时序 + +本文整理 `Meshtastic` Android 客户端与当前 `trail-mate` nRF52 固件之间的 BLE 交互时序,目的是为后续排查 “App 一直停在正在连接” 和 `FromRadio/FromNum` 兼容问题提供统一基线。 + +本文不讨论 UI、GPS、LoRa 或 flash 持久化问题,只聚焦 Meshtastic BLE 握手、配置流和连接完成判定。 + +## 1. 总体结论 + +Android 端的 Meshtastic BLE 连接并不是: + +- 连接成功 +- 收到一两个通知 +- 立刻视为已连接 + +它真正的判定是两阶段配置握手: + +1. GATT 连接建立,完成服务发现和通知订阅。 +2. App 发出 Stage 1:`ToRadio.want_config_id = CONFIG_NONCE` +3. 设备返回一整段配置流,直到 `config_complete_id(CONFIG_NONCE)` +4. App 发出 Stage 2:`ToRadio.want_config_id = NODE_INFO_NONCE` +5. 设备返回一整段 `node_info` 流,直到 `config_complete_id(NODE_INFO_NONCE)` +6. App 才把连接状态从 `Connecting` 切到 `Connected` + +因此,App 长时间停在“正在连接”,通常不表示 BLE 物理链路没有连上,而是表示 Meshtastic 的配置握手尚未被完整认账。 + +## 2. Android 端时序 + +### 2.1 连接建立 + +Android 端 BLE 入口在: + +- `.tmp/meshtastic-android/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt` +- `.tmp/meshtastic-android/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt` + +高层时序: + +1. `BleRadioInterface.connect()` 建立 GATT 连接 +2. `discoverServicesAndSetupCharacteristics()` 完成 service/profile 建立 +3. `fromRadio` / `logRadio` 的 observation 被启动 +4. 经过一个很短的 `CCCD_SETTLE_MS` 等待窗口 +5. 调用 `service.onConnect()` + +对应关键代码位置: + +- `BleRadioInterface.discoverServicesAndSetupCharacteristics()` +- `BleRadioInterface.service.onConnect()` + +### 2.2 App 如何读取 FromRadio + +Android 端并不是单纯依赖 `FROMNUM` 通知。 + +`KableMeshtasticRadioProfile.fromRadio` 的行为如下: + +1. 如果设备支持 `FROMRADIOSYNC`,则直接订阅该特征。 +2. 如果不支持,则退回到 legacy 模式: + - 订阅 `FROMNUM` + - 但是同时主动触发一次 drain + - 然后循环读取 `FROMRADIO` + - 一直读到返回空包为止 + +更具体地说,legacy 模式里会发生两件关键动作: + +1. `triggerDrain.tryEmit(Unit)` 会在 collector 启动时主动触发一次。 +2. `sendToRadio()` 每发送一条 `ToRadio`,也会再次 `triggerDrain.tryEmit(Unit)`。 + +这意味着: + +- 发送 `want_config_id` 后,App 会主动开始 `read(FROMRADIO)` +- 即使某一瞬间没有 `FROMNUM` 通知,App 也不一定会停住 +- `FROMNUM` 更像 steady-state 提示,而不是唯一驱动条件 + +对应关键代码位置: + +- `KableMeshtasticRadioProfile.fromRadio` +- `service.observe(fromNum)` +- `service.read(fromRadioChar)` +- `triggerDrain.tryEmit(Unit)` +- `sendToRadio()` + +### 2.3 两阶段握手 + +上层状态机在: + +- `.tmp/meshtastic-android/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt` +- `.tmp/meshtastic-android/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt` +- `.tmp/meshtastic-android/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt` + +#### Stage 1 + +1. 连接建立后,`MeshConnectionManagerImpl.handleConnected()` 调用 `startConfigOnly()` +2. `startConfigOnly()` 发送: + - `ToRadio.want_config_id = HandshakeConstants.CONFIG_NONCE` +3. App 开始消费 `FromRadio` +4. `FromRadioPacketHandlerImpl` 将不同 variant 分发到 config flow manager / config handler +5. 当收到: + - `FromRadio.config_complete_id == CONFIG_NONCE` +6. `MeshConfigFlowManagerImpl.handleConfigComplete()` 进入 Stage 1 complete + +Stage 1 期间典型接收内容包括: + +- `my_info` +- `deviceui` +- `metadata` +- `config` +- `moduleConfig` +- `channel` +- `fileInfo` + +#### Stage 2 + +Stage 1 完成后: + +1. `MeshConfigFlowManagerImpl.handleConfigOnlyComplete()` 先发送一个 heartbeat +2. 然后调用 `startNodeInfoOnly()` +3. 发送: + - `ToRadio.want_config_id = HandshakeConstants.NODE_INFO_NONCE` +4. App 接收一串 `node_info` +5. 当收到: + - `FromRadio.config_complete_id == NODE_INFO_NONCE` +6. `MeshConfigFlowManagerImpl.handleNodeInfoComplete()` 才真正执行: + - `serviceRepository.setConnectionState(ConnectionState.Connected)` + +也就是说,只有 Stage 2 完成,Android 才认为连接完成。 + +## 3. 当前固件侧时序 + +当前 nRF52 侧入口主要在: + +- `platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp` +- `modules/core_chat/src/ble/meshtastic_phone_core.cpp` + +### 3.1 BLE service 层 + +当前 Meshtastic BLE service 暴露的关键特征: + +- `ToRadio` +- `FromRadio` +- `FromNum` +- `LogRadio` + +主循环的关键顺序目前是: + +1. `processPendingToRadio()` +2. `handleToPhone()` +3. `prepareReadableFromRadio()` + +也就是: + +- 先处理手机写入的 `ToRadio` +- 再让 `MeshtasticPhoneCore` 产出下一帧 `FromRadio` +- 再把下一帧预装进 `FromRadio` characteristic,等待 App 读取 + +### 3.2 PhoneCore 配置流 + +`MeshtasticPhoneCore` 在收到 `want_config_id` 后会开始吐配置快照。 + +当前日志里能看到的配置流顺序大致是: + +1. `cfg#N start` +2. `frame my_info` +3. `frame deviceui` +4. `frame self_node` +5. 后续 metadata/config/module/channel/node 等若干帧 +6. `cfg#N complete` + +对应日志前缀: + +- `[BLE][mtcore][cfg#N] start` +- `[BLE][mtcore][cfg#N] frame ...` +- `[BLE][mtcore][cfg#N] complete` + +每帧编码后都会成为一条 `MeshtasticBleFrame`,交给 transport 层。 + +### 3.3 FromNum / FromRadio 当前实现 + +当前 nRF52 transport 的基本模型是: + +1. `PhoneCore.popToPhone()` 产出一帧 +2. `handleToPhone()` 将其缓存为待发送帧 +3. `prepareReadableFromRadio()` 把这帧写入 `FromRadio` +4. `notifyFromNum(from_num)` 发出一条 `FromNum` 通知 +5. App 如果读取 `FromRadio`,则走 `onFromRadioAuthorize()` +6. 读成功后 `markReadableFromRadioConsumed()` 清空当前可读帧 + +为了排障,目前固件还打印了这些日志: + +- `[BLE][nrf52][mt][flow] link-up ...` +- `[BLE][nrf52][mt][flow] from_num subscribed=...` +- `[BLE][nrf52][mt][flow] preload from_num=... len=...` +- `[BLE][nrf52][mt][flow] from_num notify=...` +- `[BLE][nrf52][mt] from_radio read len=...` +- `[BLE][nrf52][mt] from_radio read empty` + +## 4. 双方时序的核心关系 + +把 Android 和固件合在一起,可以归纳成下面这条主线: + +1. GATT connected +2. App 订阅 `FROMNUM` / `LOGRADIO`,并建立 `fromRadio` collector +3. App 调用 `service.onConnect()` +4. App 发送 `want_config_id = CONFIG_NONCE` +5. App 主动开始 drain `FROMRADIO` +6. 固件一帧一帧提供: + - `my_info` + - `deviceui` + - `self_node` + - ... + - `config_complete(CONFIG_NONCE)` +7. App 切到 Stage 2,发送 `want_config_id = NODE_INFO_NONCE` +8. App 再次 drain `FROMRADIO` +9. 固件提供若干 `node_info` +10. 固件发送 `config_complete(NODE_INFO_NONCE)` +11. App 切为 `Connected` + +## 5. 当前 nRF52 侧最值得关注的偏离点 + +基于 Android 端真实代码,当前最重要的观察点不是“有没有大量 `FROMNUM notify`”,而是以下几条。 + +### 5.1 App 是否真的在读 FromRadio + +由于 Android 在发送 `want_config` 后会主动 drain `FROMRADIO`,所以如果固件日志里看不到: + +- `[BLE][nrf52][mt] from_radio read len=...` +- `[BLE][nrf52][mt] from_radio read empty` + +那问题更像是: + +- `FromRadio` 的 GATT read 在 nRF52/Bluefruit 上没有真正对上 App 的读取 +- 而不是配置内容本身不对 + +### 5.2 空包语义是否闭环 + +Android 端 legacy 模式会一直 `read(FROMRADIO)`,直到返回空包才结束本轮 drain。 + +因此固件必须保证: + +1. 有帧时,read 返回当前帧 +2. 当前帧被读走后,下一次 read 应该拿到下一帧 +3. 本轮没有更多帧时,必须返回空包 + +如果最后一步没有成立,App 可能一直认为配置流没有完整结束。 + +### 5.3 Stage 1 完成不等于连接完成 + +即使 `cfg#1 complete` 已经在固件日志里出现,App 也仍可能显示“正在连接”。 + +因为对 Android 来说: + +- Stage 1 complete 只是配置读取完成 +- 还需要再跑一次 Stage 2 node-info 握手 +- 只有第二个 `config_complete_id` 到达,状态才会变成 `Connected` + +因此任何只完成 Stage 1 的链路,都会让 UI 继续停留在 `Connecting` + +### 5.4 历史问题:loop 栈溢出 + +之前 nRF52 侧已经确认过一个与 BLE 配置流强相关的历史问题: + +- 配置流构造路径曾把 `loop` 任务栈压到 `stack_hwm=0` +- 这会导致同任务中的其他对象被踩坏 +- 表现为 GPS 状态损坏、`SAT` 数异常、guard 被 `[BLE` 字样改写 + +该问题经过 `MeshtasticPhoneCore` 的大对象减栈后已经明显缓解,但它说明: + +- Meshtastic 配置流不是“普通小开销路径” +- 任何时序分析都需要连同任务上下文和内存行为一起看 + +## 6. 用这份时序来判定故障 + +后续排障可以按下面的判定法来做。 + +### 情况 A + +现象: + +- 有 `cfg#start` +- 有 `cfg#complete` +- 但没有任何 `from_radio read ...` + +判断: + +- App 已经发出 `want_config` +- 但 `FROMRADIO` 读路径没有真正命中 nRF52 固件 +- 应重点排查 `FromRadio` characteristic 的 GATT read 兼容性 + +### 情况 B + +现象: + +- 有 `from_radio read len=...` +- 但没有 `from_radio read empty` + +判断: + +- drain-until-empty 没闭环 +- App 很可能还在等待本轮读取结束 + +### 情况 C + +现象: + +- Stage 1 的 `config_complete(CONFIG_NONCE)` 已发送 +- 但 App 没进入第二次 `want_config_id` + +判断: + +- App 没有成功消费到 Stage 1 完成信号 +- 应优先核对 `config_complete_id` 帧是否真的到达 Android `FromRadioPacketHandler` + +### 情况 D + +现象: + +- Stage 2 也已经完成 +- 但 App 还是 `Connecting` + +判断: + +- 应核对 Android 侧 `MeshConfigFlowManagerImpl.handleNodeInfoComplete()` 是否真的被触发 +- 或排查 Stage 2 期间是否存在 node-info 流中断 / 状态机被回退 + +## 7. 后续建议 + +后续所有 Meshtastic BLE 修复,都应优先对照本文中的这几条事实: + +1. Android 会主动 drain `FROMRADIO` +2. `FROMNUM` 不是唯一驱动条件 +3. 连接完成依赖两阶段 `config_complete_id` +4. `FROMRADIO` 必须具备“连续读帧直到空包”的稳定语义 +5. nRF52 上不仅要关注协议顺序,还要关注任务栈和回调上下文 + +如果后续继续调试,建议优先保留以下日志: + +- `[BLE][nrf52][mt][flow] link-up ...` +- `[BLE][nrf52][mt][flow] from_num subscribed=...` +- `[BLE][nrf52][mt][flow] preload ...` +- `[BLE][nrf52][mt][flow] from_num notify=...` +- `[BLE][nrf52][mt] from_radio read len=...` +- `[BLE][nrf52][mt] from_radio read empty` +- `[BLE][mtcore][cfg#N] start/frame/complete` +- `[BLE][mtcore][rt] stage=... stack_hwm=...` + +这些日志已经足够把问题收敛到: + +- GATT 读不到 +- drain 语义不闭环 +- Stage 1 未完成 +- Stage 2 未完成 +- 或运行时栈/内存问题 diff --git a/docs/team/persist.md b/docs/team/persist.md index 7aab95ad..8eae88d4 100644 --- a/docs/team/persist.md +++ b/docs/team/persist.md @@ -320,8 +320,10 @@ ChatRecHeaderV1 { text[text_len] // UTF-8 ``` -#### Team chat????????V2, TEAM_CHAT_APP? -???? TeamChat ?????????? V2 ?? chatlog.log? +#### Team chat 记录格式(V2,对应 `TEAM_CHAT_APP`) + +当收到 `TeamChat` 负载并完成解码后,按 V2 结构追加写入 `chatlog.log`。 +相比 V1,V2 在记录头中额外保存消息类型,便于统一回放文本、位置和指令类消息。 ```c ChatRecHeaderV2 { @@ -340,7 +342,6 @@ ChatRecHeaderV2 { } payload[payload_len] // decoded TeamChat payload ``` -``` #### 上限策略(简单) @@ -598,4 +599,3 @@ Member Core Mesh Leader Core * payload 必须能解码为 `meshtastic_Position`,否则丢弃(或统计 error) * 不做自定义版本字段检查(以 Meshtastic 协议为准) - diff --git a/docs/team/uiux.md b/docs/team/uiux.md index e489332a..b79fe505 100644 --- a/docs/team/uiux.md +++ b/docs/team/uiux.md @@ -1,23 +1,23 @@ # A. 全部页面一览(按状态完整覆盖) -> 设备:**2.33-inch 横屏 222×480** -> 约束:**固定 TopBar(Back / Title / Battery)** -> 原则:**ESP‑NOW 近距建队(≤5m)**;不使用 LoRa/NFC;配对仅在对应页面启用(省电/避免误触) +> 设备:2.33-inch 横屏,分辨率 222 x 480 +> 约束:固定 TopBar(Back / Title / Battery) +> 原则:近距离组队优先使用 `ESP-NOW`,不依赖 LoRa / NFC;配对只在对应页面启用,避免误触和持续耗电。 --- ## A0. 全局 UI 骨架(复用) -``` -┌──────────────────────────────────────────────┐ -│ < Back [ TITLE / CONTEXT ] 🔋 │ -├──────────────────────────────────────────────┤ -│ │ -│ CONTENT AREA │ -│ │ -├──────────────────────────────────────────────┤ -│ [ Action 1 ] [ Action 2 ] │ (可选) -└──────────────────────────────────────────────┘ +```text ++------------------------------------------------+ +| < Back [ TITLE / CONTEXT ] Bat | ++------------------------------------------------+ +| | +| CONTENT AREA | +| | ++------------------------------------------------+ +| [ Action 1 ] [ Action 2 ] | ++------------------------------------------------+ ``` --- @@ -26,22 +26,13 @@ **Title:`Team`** -``` -┌──────────────────────────────────────────────┐ -│ < Back Team 🔋 │ -├──────────────────────────────────────────────┤ -│ │ -│ You are not in a team │ -│ │ -│ • No shared map │ -│ • No team awareness │ -│ │ -│ Create or join a team │ -│ │ -├──────────────────────────────────────────────┤ -│ [ Create (ESP?NOW) ] [ Join (ESP?NOW) ] │ -└──────────────────────────────────────────────┘ -``` +- 主文案:You are not in a team +- 说明: + - No shared map + - No team awareness +- 主动作: + - `Create (ESP-NOW)` + - `Join (ESP-NOW)` --- @@ -49,70 +40,69 @@ **Title:`Team Status`** -``` -┌──────────────────────────────────────────────┐ -│ < Back Team Status 🔋 │ -├──────────────────────────────────────────────┤ -│ Team: ALPHA-7 (ID: A7K3) │ -│ Role: Member │ -│ Members: 5 Online: 3 │ -│ Security: OK (Epoch 5) │ -│ Sync: OK (Last event 128) │ -│ │ -├──────────────────────────────────────────────┤ -│ Team Health │ -│ ────────────────────────────────────────── │ -│ ● Leader online │ -│ ● Last update 18s ago │ -│ ○ 1 member stale │ -│ │ -├──────────────────────────────────────────────┤ -│ [ View Team ] [ Pair Member ] [ Leave ] │ -└──────────────────────────────────────────────┘ -``` +- 展示字段: + - Team name / Team ID + - Role + - Members / Online + - Security / Epoch + - Sync / Last event +- Team Health: + - Leader online + - Last update age + - stale member count +- 主动作: + - `View Team` + - `Pair Member` + - `Leave` -> 这是 **判断“队伍是否还可信 / 是否对齐”** 的页面 -> 不是管理页 +这是判断“队伍是否健康、是否同步完成”的总览页,不承担复杂管理职责。 --- -## A3. Team Home(成员与结构,Leader/Member 通用) +## A3. Team Home(成员与结构) -**Title:`Team · Leader` 或 `Team · Member`** +**Title:`Team / Leader` 或 `Team / Member`** -``` -┌──────────────────────────────────────────────┐ -│ < Back Team · Leader 🔋 │ -├──────────────────────────────────────────────┤ -│ Team: ALPHA-7 (ID: A7K3) │ -│ Members: 3 Online: 2 │ -│ Epoch: 5 Sync: OK (128) │ -│ │ -├──────────────────────────────────────────────┤ -│ │ (仅 Leader 且有请求时显示) -│ ────────────────────────────────────────── │ -│ Members │ -│ ● You (Leader) Online │ -│ ● Tom Online │ -│ ○ Jerry Last seen 2m ago │ -│ │ -├──────────────────────────────────────────────┤ -│ [ Pair Member ] [ Manage ] [ Leave ] │ -└──────────────────────────────────────────────┘ -``` +- 展示字段: + - Team / ID + - Members / Online + - Epoch + - Sync status +- 列表内容: + - 成员名 + - 在线状态 + - 最近在线时间 +- 主动作: + - `Pair Member` + - `Manage`(Leader 可见) + - `Leave` -> 重要:户外场景下用户经常在地图/聊天页,不一定看得到 popup +说明:户外场景里用户更常停留在地图页或聊天页,因此关键状态不能只靠弹窗提示。 --- -## A3b. Pairing?ESP?NOW? -**Title?`Pairing`** +## A3b. Pairing(ESP-NOW) -- ??????? ESP?NOW ??????? 120s? -- Leader????????????????? -- Member??? beacon??? join ????? KeyDist -- UI ???Scanning / Join sent / Waiting for keys / Completed / Failed -- ???[Cancel] ?????[Retry] ???? +**Title:`Pairing`** + +- 配对窗口有效期:120 秒 +- Leader 侧: + - 广播可加入状态 + - 接受加入请求 + - 下发密钥和初始快照 +- Member 侧: + - 扫描 beacon + - 发送 join 请求 + - 等待 Key Distribution +- UI 状态: + - `Scanning` + - `Join sent` + - `Waiting for keys` + - `Completed` + - `Failed` +- 动作: + - `Cancel` + - `Retry` --- @@ -120,127 +110,98 @@ **Title:`Members`** -``` -┌──────────────────────────────────────────────┐ -│ < Back Members 🔋 │ -├──────────────────────────────────────────────┤ -│ ● You (Leader) │ -│ │ -│ ● Tom │ -│ > Select │ -│ │ -│ ○ Jerry │ -│ > Select │ -│ │ -└──────────────────────────────────────────────┘ -``` +- 列表内容: + - You (Leader) + - 普通成员 + - 每个成员带 `Select` +- 用途:进入单成员详情,发起踢人或转移 Leader。 --- ## A8. Member Detail(Leader) -**Title:`Member: Jerry`** +**Title:`Member: `** -``` -┌──────────────────────────────────────────────┐ -│ < Back Member: Jerry 🔋 │ -├──────────────────────────────────────────────┤ -│ Status: Last seen 2m ago │ -│ Role: Member │ -│ │ -│ Device: Pager │ -│ Capability: │ -│ • Position │ -│ • Waypoint │ -│ │ -├──────────────────────────────────────────────┤ -│ [ Kick ] [ Transfer Leader ] │ -└──────────────────────────────────────────────┘ -``` +- 展示: + - Status + - Role + - Device + - Capability(Position / Waypoint 等) +- 动作: + - `Kick` + - `Transfer Leader` --- -## A9. Kick 确认页(安全关键) +## A9. Kick 确认页 **Title:`Kick Member`** -``` -┌──────────────────────────────────────────────┐ -│ < Back Kick Member 🔋 │ -├──────────────────────────────────────────────┤ -│ Remove Jerry from team? │ -│ │ -│ This will update the security round (epoch). │ -│ Jerry will no longer receive team updates. │ -│ │ -├──────────────────────────────────────────────┤ -│ [ Cancel ] [ Confirm Kick ] │ -└──────────────────────────────────────────────┘ -``` +- 文案: + - Remove `` from team? + - This will update the security round (`epoch`). + - The removed member will no longer receive team updates. +- 动作: + - `Cancel` + - `Confirm Kick` --- -## A9b. Leave 确认弹窗 +## A9b. Leave 确认页 -``` -┌──────────────────────────────────────────────┐ -│ < Back Leave team? 🔋 │ -├──────────────────────────────────────────────┤ -│ This clears local keys. │ -│ │ -├──────────────────────────────────────────────┤ -│ [ Cancel ] [ Leave ] │ -└──────────────────────────────────────────────┘ -``` +**Title:`Leave team?`** -> Leave 需要二次确认,避免误触导致本地密钥被清空 +- 文案: + - This clears local keys. +- 动作: + - `Cancel` + - `Leave` + +`Leave` 需要二次确认,避免误触导致本地密钥被清空。 --- -## A10. Access Lost(Member:被踢 / 失效 / 不一致) +## A10. Access Lost(Member:被移除 / 失步 / 异常) **Title:`Team`** -``` -┌──────────────────────────────────────────────┐ -│ < Back Team 🔋 │ -├──────────────────────────────────────────────┤ -│ Access lost │ -│ │ -│ Reason: [ Revoked | Out-of-sync | Unknown ] │ -│ │ -│ • Revoked: removed by leader │ -│ • Out-of-sync: team updated, sync required │ -│ │ -├──────────────────────────────────────────────┤ -│ [ Try Sync ] [ Join Another Team ] [ OK ]│ -└──────────────────────────────────────────────┘ -``` +- 状态:Access lost +- 原因: + - `Revoked` + - `Out-of-sync` + - `Unknown` +- 说明: + - Revoked:被 Leader 移除 + - Out-of-sync:队伍已经更新,本机需要同步 +- 动作: + - `Try Sync` + - `Join Another Team` + - `OK` -> 关键:区分“被踢” vs “密钥/epoch 不一致”,减少误判 -> `Try Sync` 只在 Out-of-sync 时启用 +关键点:要明确区分“被踢出”和“epoch 不一致”,减少误判。 --- # B. 页面流转说明(UI 状态机) -## B1. 顶层流转(简化) +## B1. 顶层流转 -``` +```text [ Team Menu ] | v [ Team Status ] | - +--> (not in team) --> [ Create (ESP?NOW) ] -> [ Team Status (joined) ] + +--> (not in team) --> [ Create / Join ] -> [ Team Status (joined) ] | +--> (joined) -------> [ View Team ] -> [ Team Home ] ``` --- -## B2. ?????Member?ESP?NOW? -``` +## B2. Member 加入流程(ESP-NOW) + +```text [ Team Status (not in team) ] | v @@ -248,13 +209,14 @@ | +-- scanning -> join sent -> waiting key -> [ Team Status (joined) ] | - +-- timeout/cancel ----------------------> [ Team Status (not in team) ] + +-- timeout / cancel --------------------> [ Team Status (not in team) ] ``` --- -## B3. ?????Leader?ESP?NOW? -``` +## B3. Leader 配对流程(ESP-NOW) + +```text [ Team Status (leader) ] | v @@ -262,14 +224,14 @@ | +-- member joins -> send keys -> [ Team Status ] | - +-- timeout/cancel --------------> [ Team Status ] + +-- timeout / cancel ----------> [ Team Status ] ``` --- ## B4. 踢人流程(Leader) -``` +```text [ Team Home ] | v @@ -288,515 +250,88 @@ # C. 涉及的协议(Pager Team Core v0.1) -## C1. ??????????? -| ?? | ?? | +## C1. 主要消息类型 + +| 类型 | 说明 | | --- | --- | -| `TEAM_KEY_DIST` | ??????? ESP?NOW? | -| `TEAM_KICK` | ?????????? | -| `TEAM_TRANSFER_LEADER` | ?????????? | -| `TEAM_STATUS` | ?????????? | -| `TEAM_POS` | ???????? | -| `TEAM_WAYPOINT` | ???????? | -| `TEAM_TRACK` | ???????? | -| `TEAM_CHAT` | ?????????? | +| `TEAM_KEY_DIST` | 通过 `ESP-NOW` 分发队伍密钥 | +| `TEAM_KICK` | 移除成员 | +| `TEAM_TRANSFER_LEADER` | 转移队长 | +| `TEAM_STATUS` | 队伍状态广播 | +| `TEAM_POS` | 成员位置同步 | +| `TEAM_WAYPOINT` | 队伍航点 | +| `TEAM_TRACK` | 轨迹数据 | +| `TEAM_CHAT` | 队伍聊天 | -> v0.2 ?????/???? ESP?NOW ??????LoRa ???? Join Handshake? ---- - -## C2. 字段命名定稿:epoch / event_seq / msg_id - -为了避免实现踩雷,明确: - -* `epoch`:**密钥轮次**(加解密 key 选择) -* `event_seq`:**关键事件序号**(仅 Key Events 与 Sync 使用,单调递增) -* `msg_id`:**普通包去重标识**(可选,v0.1 可不做) +`v0.2` 再考虑把部分消息扩展到 LoRa,`v0.1` 先把 Join Handshake 和本地状态闭环跑通。 --- -## C3. TeamEnvelope(所有 Team 包统一外壳,v0.1) +## C2. 字段命名约定:`epoch` / `event_seq` / `msg_id` -> `event_seq` 只在关键事件或 sync 承载事件时出现; -> Presence/Pos 不要求连续 seq。 +- `epoch`:密钥轮次,用于选择当前有效 key。 +- `event_seq`:关键事件序号,只用于关键事件同步,单调递增。 +- `msg_id`:普通消息去重标识,可选;`v0.1` 可以先不做。 -``` +--- + +## C3. `TeamEnvelope` + +所有 Team 消息统一包在一个外层结构里: + +```text TeamEnvelope { team_id epoch type sender_id timestamp - msg_id? // optional, v0.1 may omit - auth // AEAD tag or MAC (depends on encrypt/plain) + msg_id? // optional + auth // AEAD tag or MAC payload } ``` ---- - -## C4. Key Events(写入 events.log 的“结构真相”) - -v0.1 必须记录的关键事件: - -* `TeamCreated(event_seq=1)` -* `MemberAccepted(event_seq++)` -* `MemberKicked(event_seq++)` -* `LeaderTransferred(event_seq++)` -* `EpochRotated(event_seq++)` - -> Key Events 是 Sync 的依据;Presence/Pos/Chat 不属于 Key Events。 +说明: +- `event_seq` 只在关键事件或同步承载关键事件时出现。 +- Presence / Position 这类消息不要求连续 `seq`。 --- -# D. 协议流转(和 UI 的对应关系) +## C4. Key Events(写入 `events.log` 的事实源) -## D1. Create (ESP?NOW) +`v0.1` 必须记录的关键事件: -* 本地生成 `team_id` -* `epoch = 1` -* self = leader -* 生成 `team_key(epoch=1)` -* 追加 key event:`TeamCreated(event_seq=1)` -* 写 snapshot + events.log +- `TeamCreated(event_seq=1)` +- `MemberAccepted(event_seq++)` +- `MemberKicked(event_seq++)` +- `LeaderTransferred(event_seq++)` +- `EpochRotated(event_seq++)` + +`Key Events` 是同步和恢复的依据;Presence / Position / Chat 不属于关键事件。 --- -## D2. Pairing / Join?ESP?NOW? +# D. 协议与 UI 的对应关系 -**Beacon?Leader?** +## D1. Create(ESP-NOW) -- Leader ?? Pairing ??????? Beacon -- Beacon ?? team_id / key_id / leader_id / team_name???? +- 本地生成 `team_id` +- 初始化 `epoch = 1` +- 写入本地快照 +- 进入 `Team Status` -**Join & KeyDist?Member?** +## D2. Join(ESP-NOW) -1. Member ?? Beacon ??? Join ?? -2. Leader ???? ESP?NOW ?? TEAM_KEY_DIST -3. Member ?? key ??? Joined??? LoRa ??? Team ?? +- 扫描 Leader 广播 +- 发送 Join 请求 +- 等待 `TEAM_KEY_DIST` +- 写入初始快照与密钥 +- 进入 `Team Status` ---- +## D3. Kick / Leave / Transfer Leader -## E2. 并发 Join 处理策略(Leader) - -* Leader 同时只处理 **1 个 pending join** - -\r\n -# 1) 模块清单(工程内目录与职责) - -## 1.1 建议目录结构 - -``` -modules/core_team/ - domain/ - team_types.h - team_model.h/.cpp - team_events.h - team_policy.h - team_crypto.h - team_codec.h // Envelope 编解码(纯函数) - - usecase/ - team_service.h/.cpp // 入口编排:UI动作 + Radio包 + 存储 - team_pairing_service.h/.cpp - team_admin_flow.h/.cpp - team_sync_flow.h/.cpp - team_presence_flow.h/.cpp - - ports/ - i_team_runtime.h // 时间/随机数/节点身份/发送能力等运行时依赖 - i_team_pairing_transport.h // Pairing 阶段传输(如 ESP-NOW) - i_team_crypto.h // 生成/派生/加解密/MAC(可在 domain 里做,但建议 port) - i_team_event_sink.h // 向上抛出 Team 事件 - i_rng.h // 随机数(team_id / key) - i_ui_notifier.h // 弹窗/Toast/页面跳转事件(可选) - - infra/ - meshtastic/ - mt_team_transport.h/.cpp // 绑定 IMeshAdapter / portnum - store/ - team_store_flash.h/.cpp // Preferences/Flash - team_store_sd.h/.cpp // 可选:SD eventlog - crypto/ - team_crypto_impl.h/.cpp // AES/ChaCha + HMAC 等 - - ui/ - screens/team/ - team_state.h/.cpp // UI state:当前页、列表数据、loading/err - team_pages.h/.cpp // enter/exit, render - team_input.h/.cpp // 按键/旋钮映射为 action - team_components.h/.cpp // list item, modal, bottom actions - team_nav.h/.cpp // 简单导航(Status/Home/Pairing...) -``` - -> 你如果已经有 “screens/xxx/ layout/components/input/state” 的范式,就完全按那套套进去。 - ---- - -## 1.2 关键职责边界 - -### Domain(纯逻辑) - -* **TeamModel**:只做状态机 + 事件应用(apply event) -* **TeamEventLog**:事件序号、重放、去重(只定义结构,不做 IO) -* **Policy**:超时阈值、invite TTL、presence 周期等(UI/配置层可改) -* **Codec**:TeamEnvelope + payload 的序列化/反序列化(不碰 radio) - -> Domain 绝不直接“发包/存盘/弹 UI”。 - -### Usecase(编排) - -* 接收 UI action → 调 domain → 调 ports(transport/store/crypto) -* 接收 radio packet → decode → 验证/解密 → apply → 必要时 sync/回包 -* 输出 UI 需要的派生数据:team health、member list、security 状态 - -### Ports/Infra - -* transport:把 “TeamPacket bytes” 发到 Meshtastic -* store:eventlog append、snapshot load/save -* crypto:key 派生、encrypt/decrypt、MAC 校验 -* clock/rng:提供可测替身(单测、仿真) - -### UI - -* 页面只关心 **显示状态** + **发 action** -* UI 不做 join/kick 的业务判断(比如 epoch rotate 必须由 usecase 做) - ---- - -# 2) 领域模型(Domain)最小定义 - -## 2.1 Team 状态(只包含 v0.1 必需) - -```cpp -enum class TeamRole : uint8_t { Leader, Member, None }; - -enum class TeamSecurityState : uint8_t { - OK, // epoch匹配且可解密 - WARN, // 需要sync或发现epoch差异但尚能工作 - FAIL // 解密失败/被踢/密钥缺失 -}; - -struct TeamId { std::array bytes; }; -struct MemberId { uint32_t node_id; }; - -struct MemberInfo { - MemberId id; - std::string name; // 可选 - TeamRole role; // Leader/Member - uint32_t last_seen_ts; // 秒 - uint32_t caps; // 位标志:pos/wp/sync -}; - -struct TeamState { - bool in_team = false; - TeamId team_id; - uint32_t epoch = 0; - TeamRole self_role = TeamRole::None; - MemberId leader_id; - - // 关键:成员集合(以事件重放得到) - std::vector members; - - // 事件序号(用于sync) - uint32_t last_event_seq = 0; - - // 安全态 - TeamSecurityState security = TeamSecurityState::FAIL; -}; -``` - -## 2.2 关键事件(可重放) - -```cpp -enum class TeamEventType : uint8_t { - TeamCreated, - MemberAccepted, - MemberKicked, - LeaderTransferred, - EpochRotated, - // v0.1 不做:复杂权限/对象 -}; - -struct TeamEvent { - uint32_t event_seq; // 单调递增(leader为权威源) - uint32_t ts; - TeamEventType type; - // payload: member_id, new_leader, new_epoch ... -}; -``` - -## 2.3 TeamModel:唯一的“真相更新入口” - -```cpp -class TeamModel { -public: - TeamState s; - - // apply must be pure: no IO - void apply(const TeamEvent& e); - - // derived - bool isLeader() const; - bool isMember() const; - const MemberInfo* findMember(MemberId id) const; -}; -``` - ---- - -# 3) Usecase 编排(核心 service + flows) - -## 3.1 TeamService / PairingService - -- TeamService ?? LoRa ?? Team ???status/pos/track/chat? -- TeamPairingService ?? ESP?NOW ?????Leader beacon / Member join / KeyDist? -- UI ?????Create (ESP?NOW) / Start Pairing / Stop Pairing / Leave / Kick / Transfer Leader -- Pairing ?? Pairing ???? - ---- - -# 4) 状态机伪代码(UI 与协议都在这里闭环) - -下面给两套状态机: - -* **UI 页面状态机**(你 LVGL 页面的流转) -* **协议/业务状态机**(join/kick/rotate/sync 的真实逻辑) - ---- - -## 4.1 UI 页面状态机(Team Screen Navigation) - -### UI 状态 - -```cpp -enum class TeamPage { - StatusNotInTeam, - StatusInTeam, - TeamHome, - JoinPending, // Pairing page - Members, - MemberDetail, - KickConfirm, - KickedOut -}; - -struct TeamUiState { - TeamPage page; - // selections - int selected_member_index = -1; - MemberId selected_member; - // join temp - std::string join_code; - // flags - bool busy = false; - std::string toast; -}; -``` - -### 页面流转伪代码(事件驱动) - -```cpp -// called when entering Team menu -void TeamUI::enter() { - auto s = teamService.snapshot(); - if (!s.in_team) navTo(StatusNotInTeam); - else navTo(StatusInTeam); -} - -// StatusNotInTeam -onClick(CreateTeam) { teamService.uiCreateTeam(); pairing.startLeader(); navTo(JoinPending); } -onClick(JoinTeam) { pairing.startMember(); navTo(JoinPending); } - -// StatusInTeam -onClick(ViewTeam) { navTo(TeamHome); } -onClick(PairMember) { pairing.startLeader(); navTo(JoinPending); } -onClick(Leave) { teamService.uiLeaveTeam(); navTo(StatusNotInTeam); } - -// TeamHome -onClick(PairMember) { pairing.startLeader(); navTo(JoinPending); } -onClick(Manage) { if (isLeader) navTo(Members); } -onClick(Leave) { ... } - -// JoinPending (Pairing) -onClick(Cancel) { pairing.stop(); navTo(previous); } -onClick(Retry) { pairing.restart(); } - -// Members -onSelect(Member) { ui.selected_member=...; navTo(MemberDetail); } - -// MemberDetail -onClick(Kick) { navTo(KickConfirm); } -onClick(TransferLeader) { teamService.uiTransferLeader(ui.selected_member); navTo(TeamHome); } - -// KickConfirm -onClick(ConfirmKick) { teamService.uiKickMember(ui.selected_member); navTo(StatusInTeam); } - -// KickedOut -onClick(JoinAnother) { navTo(StatusNotInTeam); } -onClick(OK) { navTo(StatusNotInTeam); } -``` - -### UI ? service ??? - -UI ????? Join/Pair Member ??????? - -* `uiCreateTeam()` -* `startPairingLeader()/startPairingMember()` -* `stopPairing()` -* `uiLeaveTeam()` / `uiKickMember()` / `uiTransferLeader()` - ---- - - - -## 4.2 ??/??????Pairing & KeyDist? - -### 4.2.1 ESP?NOW Pairing ?? - -- Leader??? Pairing ?? -> ?? Beacon -> ?? Join ??? TEAM_KEY_DIST -- Member??? Beacon -> Join Sent -> Waiting Key -> Joined -- ??????? / ?? -> ?? StatusNotInTeam - -### 4.2.2 KeyDist ?? - -- ?? TEAM_KEY_DIST ??? key??? snapshot??? Joined - ---- - -## 4.2.10 Kick Flow(Leader → 全队) - -```cpp -void TeamService::uiKickMember(MemberId target) { - if (!model_.isLeader()) return; - - // 1) event: MemberKicked(target) - appendEventAndApply(makeMemberKickedEvent(target)); - - // 2) epoch rotate - rotateEpoch(); - - // 3) broadcast kick + epoch rotate marker(可合并成一个 control 包) - transport_.send(encodeKick(model_.s.team_id, model_.s.epoch, target)); - - // 4) distribute new key to remaining members - distributeEpochKeyToMembers(); -} -``` - -### 被踢成员的处理(Member 侧) - -```cpp -void TeamService::onKick(const Kick& k) { - if (!model_.s.in_team) return; - if (k.target != selfNodeId()) { - // 其他人被踢:apply事件、等待key更新 - appendEventAndApply(makeMemberKickedEvent(k.target)); - model_.s.security = TeamSecurityState::WARN; - return; - } - - // 自己被踢:立即失效 - model_.s.in_team = false; - model_.s.security = TeamSecurityState::FAIL; - crypto_.wipeTeamKeys(model_.s.team_id); - store_.clearTeam(); // 或标记 revoked - ui_->navToKickedOut(); -} -``` - ---- - -## 4.2.11 Presence & Health(状态页数据来源) - -```cpp -void TeamService::broadcastPresenceIfDue() { - if (!model_.s.in_team) return; - if (clock_.now() - last_presence_ts < policy.presence_period_s) return; - - auto pkt = encodePresence(model_.s.team_id, model_.s.epoch, - selfNodeId(), - /*event_seq*/ model_.s.last_event_seq, - /*battery*/ readBattery(), - /*fix*/ gpsFix()); - transport_.send(pkt); - last_presence_ts = clock_.now(); -} - -void TeamService::onPresence(const Presence& p) { - if (!acceptTeam(p.team_id)) return; - - // epoch mismatch: warn + possible sync - if (p.epoch != model_.s.epoch) { - model_.s.security = TeamSecurityState::WARN; - maybeSync(p); - } - - updateMemberLastSeen(p.sender, p.ts); - if (p.event_seq > model_.s.last_event_seq) { - requestSyncFrom(p.sender, model_.s.last_event_seq + 1); - } -} -``` - ---- - -## 4.2.12 Sync(补齐关键事件) - -```cpp -void TeamService::requestSyncFrom(MemberId peer, uint32_t from_seq) { - auto req = encodeSyncReq(model_.s.team_id, model_.s.epoch, from_seq); - transport_.sendTo(peer, req); -} - -void TeamService::onSyncReq(const SyncReq& r, MemberId from) { - // 从 store 拉最近N条 event 回包 - auto events = store_.readEventsFrom(r.from_seq, policy.sync_max_events); - transport_.sendTo(from, encodeSyncRsp(model_.s.team_id, model_.s.epoch, events)); -} - -void TeamService::onSyncRsp(const SyncRsp& rsp) { - for (auto& e : rsp.events) { - if (e.event_seq <= model_.s.last_event_seq) continue; - model_.apply(e); - store_.appendEvent(e); - } - store_.saveSnapshot(model_.s); - model_.s.security = TeamSecurityState::OK; -} -``` - ---- - -# 5) UI 与协议“对齐点清单”(你落地时最有用) - -## 5.1 ??????? usecase - -* StatusNotInTeam?Create ? `uiCreateTeam()` + `startPairingLeader()`?Join ? `startPairingMember()` -* JoinPending?Pairing??Cancel ? `stopPairing()`?Retry ? `startPairing...()` -* StatusInTeam / TeamHome?Pair Member ? `startPairingLeader()` -* Members/Detail/KickConfirm?Kick/Transfer ? `uiKickMember()/uiTransferLeader()` -* KickedOut?Join Another ? ? StatusNotInTeam - -## 5.2 页面字段来自哪里 - -* Members/Online:`TeamService.snapshot()` -* Security OK/WARN/FAIL:来自 - - * 解密成功与否(crypto) - * epoch 是否一致(presence/控制包) - * sync 是否完成 -* Last update:来自 - - * 最近一次 presence/控制事件处理时间 - ---- - -# 6) 你可以直接照这个开工的“第一批文件” - -如果你想最快把骨架跑起来,我建议按这个顺序建文件: - -1. `modules/core_team/include/team/domain/team_types.h` + `modules/core_team/include/team/domain/team_events.h` -2. `modules/core_team/include/team/usecase/team_service.h` + `modules/core_team/src/usecase/team_service.cpp` -3. `modules/core_team/include/team/ports/i_team_pairing_transport.h` + `modules/core_team/include/team/ports/i_team_runtime.h` -4. `modules/core_team/src/usecase/team_controller.cpp` + `modules/core_team/src/usecase/team_pairing_coordinator.cpp` -5. `modules/ui_shared/include/ui/screens/team/team_state.h` + `apps/esp_pio/src/ui_team.cpp`(StatusNotInTeam / StatusInTeam 两页先跑起来) -6. 再加 join/kick/sync +- 这些都是关键事件 +- 必须更新 `event_seq` +- 必要时轮换 `epoch` +- UI 成功态统一回到 `Team Status` diff --git a/images/aprs.png b/images/aprs.png new file mode 100644 index 0000000000000000000000000000000000000000..16a47005041a0339f0d079a5d8b9eb8c0948fc6e GIT binary patch literal 2436 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 + +namespace ble::meshtastic_defaults +{ + +constexpr uint32_t kConfigNonceOnlyConfig = 69420U; +constexpr uint32_t kConfigNonceOnlyNodes = 69421U; +constexpr uint8_t kMaxMeshtasticChannels = 8U; +constexpr uint32_t kOfficialMinAppVersion = 30200U; +constexpr uint32_t kOfficialDeviceStateVersion = 24U; +constexpr const char* kCompatFirmwareVersion = "2.7.4.0"; +constexpr uint32_t kModuleConfigVersion = 1U; + +constexpr const char* kDefaultMqttAddress = "mqtt.meshtastic.org"; +constexpr const char* kDefaultMqttUsername = "meshdev"; +constexpr const char* kDefaultMqttPassword = "large4cats"; +constexpr const char* kDefaultMqttRoot = "msh"; +constexpr bool kDefaultMqttEncryptionEnabled = true; +constexpr bool kDefaultMqttTlsEnabled = false; +constexpr uint32_t kDefaultMapPublishIntervalSecs = 60U * 60U; +constexpr uint32_t kDefaultDetectionMinBroadcastSecs = 45U; +constexpr uint32_t kDefaultAmbientCurrent = 10U; + +} // namespace ble::meshtastic_defaults diff --git a/modules/core_chat/include/chat/ble/meshtastic_phone_core.h b/modules/core_chat/include/chat/ble/meshtastic_phone_core.h index e6f8a20d..60e430d0 100644 --- a/modules/core_chat/include/chat/ble/meshtastic_phone_core.h +++ b/modules/core_chat/include/chat/ble/meshtastic_phone_core.h @@ -98,6 +98,14 @@ class MeshtasticPhoneCore void enqueueConfigSnapshot(uint32_t config_nonce); void enqueueFromRadio(const meshtastic_FromRadio& from, uint32_t from_num); void notifyFromNum(uint32_t from_num); + void fillMyInfo(meshtastic_MyNodeInfo* out) const; + void fillSelfNodeInfo(meshtastic_NodeInfo* out) const; + void fillNodeInfoFromEntry(const chat::contacts::NodeEntry& entry, meshtastic_NodeInfo* out) const; + void fillMetadata(meshtastic_DeviceMetadata* out) const; + void fillDeviceUi(meshtastic_DeviceUIConfig* out) const; + void fillChannel(uint8_t idx, meshtastic_Channel* out) const; + void fillConfig(meshtastic_AdminMessage_ConfigType type, meshtastic_Config* out) const; + void fillModuleConfig(meshtastic_AdminMessage_ModuleConfigType type, meshtastic_ModuleConfig* out) const; meshtastic_MyNodeInfo buildMyInfo() const; meshtastic_NodeInfo buildSelfNodeInfo() const; meshtastic_NodeInfo buildNodeInfoFromEntry(const chat::contacts::NodeEntry& entry) const; @@ -119,9 +127,11 @@ class MeshtasticPhoneCore uint8_t config_channel_index_ = 0; uint8_t config_type_index_ = 0; uint8_t config_module_type_index_ = 0; + uint32_t config_request_seq_ = 0; uint8_t last_to_radio_[meshtastic_ToRadio_size] = {}; size_t last_to_radio_len_ = 0; bool config_flow_active_ = false; + bool config_drain_empty_pending_ = false; std::deque frame_queue_; std::deque queue_status_queue_; std::deque packet_queue_; @@ -129,6 +139,12 @@ class MeshtasticPhoneCore meshtastic_LocalModuleConfig module_config_ = meshtastic_LocalModuleConfig_init_zero; char admin_canned_messages_[160] = {}; char admin_ringtone_[96] = {}; + meshtastic_ToRadio to_radio_scratch_ = meshtastic_ToRadio_init_zero; + meshtastic_AdminMessage admin_req_scratch_ = meshtastic_AdminMessage_init_zero; + meshtastic_AdminMessage admin_resp_scratch_ = meshtastic_AdminMessage_init_zero; + meshtastic_MeshPacket reply_packet_scratch_ = meshtastic_MeshPacket_init_zero; + meshtastic_MqttClientProxyMessage mqtt_proxy_scratch_ = meshtastic_MqttClientProxyMessage_init_zero; + meshtastic_FromRadio from_radio_scratch_ = meshtastic_FromRadio_init_zero; }; } // namespace ble diff --git a/modules/core_chat/include/chat/domain/contact_types.h b/modules/core_chat/include/chat/domain/contact_types.h index 423469eb..7f8bb88b 100644 --- a/modules/core_chat/include/chat/domain/contact_types.h +++ b/modules/core_chat/include/chat/domain/contact_types.h @@ -6,6 +6,7 @@ #pragma once #include +#include #include namespace chat @@ -62,6 +63,74 @@ struct NodePosition uint32_t gps_accuracy_mm = 0; }; +struct NodeDeviceMetrics +{ + bool has_battery_level = false; + uint32_t battery_level = 0; + bool has_voltage = false; + float voltage = 0.0f; + bool has_channel_utilization = false; + float channel_utilization = 0.0f; + bool has_air_util_tx = false; + float air_util_tx = 0.0f; + bool has_uptime_seconds = false; + uint32_t uptime_seconds = 0; +}; + +struct NodeUpdate +{ + const char* short_name = nullptr; + const char* long_name = nullptr; + + bool has_last_seen = false; + uint32_t last_seen = 0; + + bool has_snr = false; + float snr = 0.0f; + + bool has_rssi = false; + float rssi = 0.0f; + + bool has_hops_away = false; + uint8_t hops_away = 0xFF; + + bool has_channel = false; + uint8_t channel = 0xFF; + + bool has_next_hop = false; + uint8_t next_hop = 0; + + bool has_protocol = false; + uint8_t protocol = 0; + + bool has_role = false; + uint8_t role = 0xFF; + + bool has_hw_model = false; + uint8_t hw_model = 0; + + bool has_macaddr = false; + uint8_t macaddr[6] = {}; + + bool has_via_mqtt = false; + bool via_mqtt = false; + + bool has_is_ignored = false; + bool is_ignored = false; + + bool has_public_key = false; + bool public_key_present = false; + + bool has_key_manually_verified = false; + bool key_manually_verified = false; + + bool has_device_metrics = false; + NodeDeviceMetrics device_metrics{}; + + bool has_position = false; + NodePosition position{}; +}; + /** * @brief Base node information */ @@ -79,6 +148,16 @@ struct NodeInfoBase std::string display_name; // nickname if contact, short_name otherwise NodeProtocolType protocol; NodeRoleType role; + uint8_t hw_model = 0; + uint8_t next_hop = 0; + bool has_macaddr = false; + uint8_t macaddr[6] = {}; + bool via_mqtt = false; + bool is_ignored = false; + bool has_public_key = false; + bool key_manually_verified = false; + bool has_device_metrics = false; + NodeDeviceMetrics device_metrics; NodePosition position; }; diff --git a/modules/core_chat/include/chat/infra/meshtastic/mt_codec_pb.h b/modules/core_chat/include/chat/infra/meshtastic/mt_codec_pb.h index 21fb3bf2..3446b6c1 100644 --- a/modules/core_chat/include/chat/infra/meshtastic/mt_codec_pb.h +++ b/modules/core_chat/include/chat/infra/meshtastic/mt_codec_pb.h @@ -42,6 +42,14 @@ bool encodeTextMessage(ChannelId channel, const std::string& text, NodeId from_node, uint32_t packet_id, NodeId dest_node, uint8_t* out_buffer, size_t* out_size); +/** + * @brief Decode an already-parsed Meshtastic Data payload into text + * @param data Decoded Meshtastic Data message + * @param out Output message + * @return true if successful + */ +bool decodeTextPayload(const meshtastic_Data& data, MeshIncomingText* out); + /** * @brief Decode Meshtastic Data payload to text message using protobuf * @param buffer Data message buffer (already decrypted) @@ -88,6 +96,7 @@ bool encodeNodeInfoMessage(const std::string& user_id, const std::string& long_n */ bool encodeAppData(uint32_t portnum, const uint8_t* payload, size_t payload_len, bool want_response, uint8_t* out_buffer, size_t* out_size); +bool decodeAppPayload(const meshtastic_Data& data, MeshIncomingData* out); bool decodeAppData(const uint8_t* buffer, size_t size, MeshIncomingData* out); /** diff --git a/modules/core_chat/include/chat/infra/node_store_core.h b/modules/core_chat/include/chat/infra/node_store_core.h index ee530dfa..1a4ce08f 100644 --- a/modules/core_chat/include/chat/infra/node_store_core.h +++ b/modules/core_chat/include/chat/infra/node_store_core.h @@ -18,13 +18,16 @@ class NodeStoreCore : public INodeStore static constexpr size_t kMaxNodes = 80; static constexpr size_t kLegacySerializedEntrySize = 64; static constexpr size_t kSerializedEntrySize = 104; - static constexpr uint8_t kPersistVersion = 7; + static constexpr size_t kSerializedEntrySizeV8 = 144; + static constexpr uint8_t kPersistVersion = 8; static constexpr uint32_t kSaveIntervalMs = 5000; explicit NodeStoreCore(INodeBlobStore& blob_store); void setProtectedNodeChecker(std::function checker); + void setAutoSaveEnabled(bool enabled); void begin() override; + void applyUpdate(uint32_t node_id, const NodeUpdate& update) override; void upsert(uint32_t node_id, const char* short_name, const char* long_name, uint32_t now_secs, float snr = 0.0f, float rssi = 0.0f, uint8_t protocol = 0, uint8_t role = kNodeRoleUnknown, uint8_t hops_away = 0xFF, @@ -36,6 +39,7 @@ class NodeStoreCore : public INodeStore bool remove(uint32_t node_id) override; const std::vector& getEntries() const override; void clear() override; + bool flush() override; static uint32_t computeBlobCrc(const uint8_t* data, size_t len); @@ -51,6 +55,7 @@ class NodeStoreCore : public INodeStore std::vector entries_; uint32_t last_save_ms_ = 0; bool dirty_ = false; + bool auto_save_enabled_ = true; std::function protected_node_checker_; }; diff --git a/modules/core_chat/include/chat/ports/i_chat_store.h b/modules/core_chat/include/chat/ports/i_chat_store.h index bc054089..5d803f79 100644 --- a/modules/core_chat/include/chat/ports/i_chat_store.h +++ b/modules/core_chat/include/chat/ports/i_chat_store.h @@ -77,6 +77,13 @@ class IChatStore * @return true if updated */ virtual bool updateMessageStatus(MessageId msg_id, MessageStatus status) = 0; + + /** + * @brief Flush pending buffered writes to persistent storage + * + * Default implementation is a no-op for stores that do not buffer. + */ + virtual void flush() {} }; } // namespace chat diff --git a/modules/core_chat/include/chat/ports/i_node_store.h b/modules/core_chat/include/chat/ports/i_node_store.h index 2d8d4b4d..b4546cf2 100644 --- a/modules/core_chat/include/chat/ports/i_node_store.h +++ b/modules/core_chat/include/chat/ports/i_node_store.h @@ -5,6 +5,7 @@ #pragma once +#include "chat/domain/contact_types.h" #include #include @@ -13,8 +14,6 @@ namespace chat namespace contacts { -struct NodePosition; - /** * @brief Node entry structure */ @@ -32,6 +31,14 @@ struct NodeEntry uint8_t protocol; // NodeProtocolType uint8_t role; // NodeRoleType (Meshtastic roles) uint8_t hw_model; // Meshtastic_HardwareModel (0 = UNSET) + bool has_macaddr = false; + uint8_t macaddr[6] = {}; + bool via_mqtt = false; + bool is_ignored = false; + bool has_public_key = false; + bool key_manually_verified = false; + bool has_device_metrics = false; + NodeDeviceMetrics device_metrics{}; bool position_valid = false; int32_t position_latitude_i = 0; int32_t position_longitude_i = 0; @@ -63,12 +70,9 @@ class INodeStore /** * @brief Update or insert a node entry - * @param node_id Node ID - * @param short_name Short name - * @param long_name Long name - * @param now_secs Current timestamp (seconds) - * @param snr Signal-to-Noise Ratio */ + virtual void applyUpdate(uint32_t node_id, const NodeUpdate& update) = 0; + virtual void upsert(uint32_t node_id, const char* short_name, const char* long_name, uint32_t now_secs, float snr = 0.0f, float rssi = 0.0f, uint8_t protocol = 0, uint8_t role = kNodeRoleUnknown, uint8_t hops_away = 0xFF, @@ -106,6 +110,12 @@ class INodeStore * @brief Clear all stored node entries */ virtual void clear() = 0; + + /** + * @brief Flush any pending dirty state to persistent storage immediately + * @return true if storage is synced or there was nothing pending + */ + virtual bool flush() = 0; }; } // namespace contacts diff --git a/modules/core_chat/include/chat/usecase/chat_service.h b/modules/core_chat/include/chat/usecase/chat_service.h index 4272f75b..9af1c03d 100644 --- a/modules/core_chat/include/chat/usecase/chat_service.h +++ b/modules/core_chat/include/chat/usecase/chat_service.h @@ -35,6 +35,20 @@ class ChatService virtual void onIncomingMessage(const ChatMessage& msg, const RxMeta* rx_meta) = 0; }; + class OutgoingTextObserver + { + public: + virtual ~OutgoingTextObserver() = default; + virtual void onOutgoingText(const MeshIncomingText& msg) = 0; + }; + + class IncomingDataObserver + { + public: + virtual ~IncomingDataObserver() = default; + virtual void onIncomingData(const MeshIncomingData& msg) = 0; + }; + ChatService(ChatModel& model, IMeshAdapter& adapter, IChatStore& store, @@ -96,12 +110,20 @@ class ChatService * @brief Process incoming messages (call from mesh task) */ void processIncoming(); + void flushStore(); void addIncomingTextObserver(IncomingTextObserver* observer); void removeIncomingTextObserver(IncomingTextObserver* observer); + void addIncomingMessageObserver(IncomingMessageObserver* observer); void removeIncomingMessageObserver(IncomingMessageObserver* observer); + void addOutgoingTextObserver(OutgoingTextObserver* observer); + void removeOutgoingTextObserver(OutgoingTextObserver* observer); + + void addIncomingDataObserver(IncomingDataObserver* observer); + void removeIncomingDataObserver(IncomingDataObserver* observer); + /** * @brief Handle send result (ack/timeout) * @param msg_id Message ID @@ -139,8 +161,11 @@ class ChatService ChannelId current_channel_; bool model_enabled_ = true; MeshProtocol active_protocol_ = MeshProtocol::Meshtastic; + std::vector incoming_text_observers_; std::vector incoming_message_observers_; + std::vector outgoing_text_observers_; + std::vector incoming_data_observers_; }; } // namespace chat diff --git a/modules/core_chat/include/chat/usecase/contact_service.h b/modules/core_chat/include/chat/usecase/contact_service.h index c7be08ad..7486c68e 100644 --- a/modules/core_chat/include/chat/usecase/contact_service.h +++ b/modules/core_chat/include/chat/usecase/contact_service.h @@ -40,6 +40,8 @@ class ContactService */ void begin(); + void applyNodeUpdate(uint32_t node_id, const NodeUpdate& update); + /** * @brief Update node info from NodeInfo packet * @param node_id Node ID @@ -80,6 +82,11 @@ class ContactService */ std::vector getNearby() const; + /** + * @brief Get ignored non-contact nodes so local admin UIs can still manage them + */ + std::vector getIgnoredNodes() const; + /** * @brief Add contact (set nickname) * @param node_id Node ID @@ -110,6 +117,22 @@ class ContactService */ bool removeNode(uint32_t node_id); + /** + * @brief Set whether a node should be ignored by nearby/discovery style UX + * @param node_id Node ID + * @param ignored True to ignore, false to unignore + * @return true if the node exists and was updated + */ + bool setNodeIgnored(uint32_t node_id, bool ignored); + + /** + * @brief Set whether a node's public key has been manually trusted/verified + * @param node_id Node ID + * @param verified True to trust, false to clear trust + * @return true if the node exists and was updated + */ + bool setNodeKeyManuallyVerified(uint32_t node_id, bool verified); + /** * @brief Get node info by node_id * @param node_id Node ID diff --git a/modules/core_chat/src/ble/meshtastic_phone_core.cpp b/modules/core_chat/src/ble/meshtastic_phone_core.cpp index b5cfe047..49795598 100644 --- a/modules/core_chat/src/ble/meshtastic_phone_core.cpp +++ b/modules/core_chat/src/ble/meshtastic_phone_core.cpp @@ -1,32 +1,51 @@ #include "chat/ble/meshtastic_phone_core.h" #include "app/app_config.h" +#include "chat/ble/meshtastic_defaults.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/runtime/self_identity_policy.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "pb_decode.h" #include "pb_encode.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/time_runtime.h" #include "sys/clock.h" #include #include #include +#include +#include #include #include #include +#include + +#if defined(__has_include) +#if __has_include() +#include +#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 1 +#elif __has_include("freertos/FreeRTOS.h") && __has_include("freertos/task.h") +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 1 +#else +#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 0 +#endif +#else +#define TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION 0 +#endif namespace ble { namespace { -constexpr uint32_t kOfficialMinAppVersion = 30200; -constexpr uint32_t kOfficialDeviceStateVersion = 24; -constexpr const char* kCompatFirmwareVersion = "2.7.4.0"; constexpr uint8_t kQueueDepthHint = 4; -constexpr uint8_t kMaxMeshtasticChannels = 8; -constexpr uint32_t kModuleConfigVersion = 1; +constexpr const char* kUiSettingsNs = "settings"; +constexpr const char* kTimezoneTzdefKey = "timezone_tzdef"; constexpr meshtastic_AdminMessage_ConfigType kConfigSnapshotTypes[] = { meshtastic_AdminMessage_ConfigType_DEVICE_CONFIG, meshtastic_AdminMessage_ConfigType_POSITION_CONFIG, @@ -83,16 +102,280 @@ void copyBounded(char* dst, size_t dst_len, const char* src) dst[dst_len - 1] = '\0'; } +bool parsePosixTzOffsetMinutes(const char* tzdef, int* out_offset_min) +{ + if (!tzdef || !out_offset_min || tzdef[0] == '\0') + { + return false; + } + + const char* cursor = tzdef; + if (*cursor == '<') + { + ++cursor; + while (*cursor != '\0' && *cursor != '>') + { + ++cursor; + } + if (*cursor != '>') + { + return false; + } + ++cursor; + } + else + { + size_t name_len = 0; + while (*cursor != '\0' && std::isalpha(static_cast(*cursor))) + { + ++cursor; + ++name_len; + } + if (name_len < 3U) + { + return false; + } + } + + int sign = 1; + if (*cursor == '-') + { + sign = -1; + ++cursor; + } + else if (*cursor == '+') + { + ++cursor; + } + + if (!std::isdigit(static_cast(*cursor))) + { + return false; + } + + int hours = 0; + while (std::isdigit(static_cast(*cursor))) + { + hours = (hours * 10) + (*cursor - '0'); + ++cursor; + } + + int minutes = 0; + if (*cursor == ':') + { + ++cursor; + if (!std::isdigit(static_cast(*cursor))) + { + return false; + } + while (std::isdigit(static_cast(*cursor))) + { + minutes = (minutes * 10) + (*cursor - '0'); + ++cursor; + } + + if (*cursor == ':') + { + ++cursor; + while (std::isdigit(static_cast(*cursor))) + { + ++cursor; + } + } + } + + const int posix_offset_min = sign * ((hours * 60) + minutes); + *out_offset_min = -posix_offset_min; + return true; +} + +void buildFixedPosixTzdef(int offset_min, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + + const int posix_offset_min = -offset_min; + const int abs_minutes = posix_offset_min < 0 ? -posix_offset_min : posix_offset_min; + const int abs_hours = abs_minutes / 60; + const int rem_minutes = abs_minutes % 60; + + if (rem_minutes == 0) + { + std::snprintf(out, out_len, "UTC%+d", posix_offset_min / 60); + } + else + { + std::snprintf(out, + out_len, + "UTC%+d:%02d", + posix_offset_min < 0 ? -abs_hours : abs_hours, + rem_minutes); + } +} + +bool loadStoredTimezoneTzdef(char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return false; + } + + out[0] = '\0'; + std::vector blob; + if (!::platform::ui::settings_store::get_blob(kUiSettingsNs, kTimezoneTzdefKey, blob) || blob.empty()) + { + return false; + } + + const size_t copy_len = std::min(out_len - 1U, blob.size()); + std::memcpy(out, blob.data(), copy_len); + out[copy_len] = '\0'; + return out[0] != '\0'; +} + +void saveStoredTimezoneTzdef(const char* tzdef) +{ + if (!tzdef || tzdef[0] == '\0') + { + const char* keys[] = {kTimezoneTzdefKey}; + ::platform::ui::settings_store::remove_keys(kUiSettingsNs, keys, 1); + return; + } + + (void)::platform::ui::settings_store::put_blob(kUiSettingsNs, kTimezoneTzdefKey, tzdef, std::strlen(tzdef) + 1U); +} + +void buildLinkedTimezoneTzdef(char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + + out[0] = '\0'; + const int current_offset_min = ::platform::ui::time::timezone_offset_min(); + char stored[65] = {}; + if (loadStoredTimezoneTzdef(stored, sizeof(stored))) + { + int parsed_offset_min = 0; + if (parsePosixTzOffsetMinutes(stored, &parsed_offset_min) && parsed_offset_min == current_offset_min) + { + copyBounded(out, out_len, stored); + return; + } + } + + buildFixedPosixTzdef(current_offset_min, out, out_len); +} + uint32_t nowSeconds() { return sys::epoch_seconds_now(); } +bool buildSelfPositionPayload(uint8_t* out_buf, size_t* out_len) +{ + if (!out_buf || !out_len || *out_len == 0) + { + return false; + } + + const platform::ui::gps::GpsState gps_state = platform::ui::gps::get_data(); + if (!gps_state.valid) + { + return false; + } + + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.latitude_i = static_cast(std::lround(gps_state.lat * 1e7)); + pos.has_longitude_i = true; + pos.longitude_i = static_cast(std::lround(gps_state.lng * 1e7)); + pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + + if (gps_state.has_alt) + { + pos.has_altitude = true; + pos.altitude = static_cast(std::lround(gps_state.alt_m)); + pos.altitude_source = meshtastic_Position_AltSource_ALT_INTERNAL; + } + if (gps_state.has_speed) + { + pos.has_ground_speed = true; + pos.ground_speed = static_cast(std::lround(gps_state.speed_mps)); + } + if (gps_state.has_course) + { + double course = gps_state.course_deg; + if (course < 0.0) + { + course = 0.0; + } + uint32_t cdeg = static_cast(std::lround(course * 100.0)); + if (cdeg >= 36000U) + { + cdeg = 35999U; + } + pos.has_ground_track = true; + pos.ground_track = cdeg; + } + if (gps_state.satellites > 0) + { + pos.sats_in_view = gps_state.satellites; + } + + const uint32_t ts = nowSeconds(); + if (ts >= 1577836800U) + { + pos.timestamp = ts; + } + + pb_ostream_t stream = pb_ostream_from_buffer(out_buf, *out_len); + if (!pb_encode(&stream, meshtastic_Position_fields, &pos)) + { + return false; + } + + *out_len = stream.bytes_written; + return true; +} + uint8_t channelIndexFromId(chat::ChannelId channel) { return (channel == chat::ChannelId::SECONDARY) ? 1U : 0U; } +void logRuntimeFootprint(const char* stage) +{ +#if TRAIL_MATE_HAS_RTOS_TASK_INTROSPECTION + const char* task_name = pcTaskGetName(nullptr); + const unsigned long stack_hwm = + static_cast(uxTaskGetStackHighWaterMark(nullptr) * sizeof(StackType_t)); + logDual("[BLE][mtcore][rt] stage=%s task=%s stack_hwm=%lu\n", + stage ? stage : "unknown", + task_name ? task_name : "?", + stack_hwm); +#else + logDual("[BLE][mtcore][rt] stage=%s task=? stack_hwm=0\n", + stage ? stage : "unknown"); +#endif +} + +const char* configStageName(uint32_t nonce) +{ + switch (nonce) + { + case meshtastic_defaults::kConfigNonceOnlyConfig: + return "stage1_config"; + case meshtastic_defaults::kConfigNonceOnlyNodes: + return "stage2_nodes"; + default: + return "stage_unknown"; + } +} + meshtastic_Config_DeviceConfig_Role roleFromEntry(uint8_t role) { switch (role) @@ -136,7 +419,7 @@ void initDefaultModuleConfig(meshtastic_LocalModuleConfig* out, uint32_t self_no } meshtastic_LocalModuleConfig zero = meshtastic_LocalModuleConfig_init_zero; *out = zero; - out->version = kModuleConfigVersion; + out->version = meshtastic_defaults::kModuleConfigVersion; out->has_mqtt = true; out->has_serial = true; out->has_external_notification = true; @@ -151,6 +434,19 @@ void initDefaultModuleConfig(meshtastic_LocalModuleConfig* out, uint32_t self_no out->has_detection_sensor = true; out->has_paxcounter = true; + copyBounded(out->mqtt.address, sizeof(out->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); + copyBounded(out->mqtt.username, sizeof(out->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); + copyBounded(out->mqtt.password, sizeof(out->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); + copyBounded(out->mqtt.root, sizeof(out->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); + out->mqtt.enabled = false; + out->mqtt.proxy_to_client_enabled = false; + out->mqtt.encryption_enabled = meshtastic_defaults::kDefaultMqttEncryptionEnabled; + out->mqtt.tls_enabled = meshtastic_defaults::kDefaultMqttTlsEnabled; + out->mqtt.has_map_report_settings = true; + out->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; + out->mqtt.map_report_settings.position_precision = 0; + out->mqtt.map_report_settings.should_report_location = false; + out->telemetry.device_update_interval = 3600; out->telemetry.device_telemetry_enabled = true; out->telemetry.environment_update_interval = 0; @@ -170,6 +466,38 @@ void initDefaultModuleConfig(meshtastic_LocalModuleConfig* out, uint32_t self_no out->ambient_lighting.blue = self_node & 0xFFU; } +void applyLegacyMqttDefaults(meshtastic_LocalModuleConfig* out) +{ + if (!out || !out->has_mqtt) + { + return; + } + + if (out->mqtt.address[0] == '\0') + { + copyBounded(out->mqtt.address, sizeof(out->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); + } + if (out->mqtt.username[0] == '\0') + { + copyBounded(out->mqtt.username, sizeof(out->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); + } + if (out->mqtt.password[0] == '\0') + { + copyBounded(out->mqtt.password, sizeof(out->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); + } + if (out->mqtt.root[0] == '\0') + { + copyBounded(out->mqtt.root, sizeof(out->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); + } + if (!out->mqtt.has_map_report_settings) + { + out->mqtt.has_map_report_settings = true; + out->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; + out->mqtt.map_report_settings.position_precision = 0; + out->mqtt.map_report_settings.should_report_location = false; + } +} + bool moduleConfigTypeFromVariant(pb_size_t variant_tag, meshtastic_AdminMessage_ModuleConfigType* out) { if (!out) @@ -247,6 +575,7 @@ MeshtasticPhoneCore::MeshtasticPhoneCore(app::IAppBleFacade& ctx, MeshtasticPhon if (hooks_->loadModuleConfig(&loaded)) { module_config_ = loaded; + applyLegacyMqttDefaults(&module_config_); } } } @@ -269,12 +598,24 @@ void MeshtasticPhoneCore::reset() void MeshtasticPhoneCore::onIncomingText(const chat::MeshIncomingText& msg) { packet_queue_.push_back(buildPacketFromText(msg)); + logDual("[BLE][mtcore] enqueue text packet id=%08lX from=%08lX to=%08lX len=%u\n", + static_cast(packet_queue_.back().id), + static_cast(packet_queue_.back().from), + static_cast(packet_queue_.back().to), + static_cast(packet_queue_.back().decoded.payload.size)); notifyFromNum(packet_queue_.back().id); } void MeshtasticPhoneCore::onIncomingData(const chat::MeshIncomingData& msg) { packet_queue_.push_back(buildPacketFromData(msg)); + logDual("[BLE][mtcore] enqueue data packet id=%08lX port=%u req=%08lX from=%08lX to=%08lX len=%u\n", + static_cast(packet_queue_.back().id), + static_cast(packet_queue_.back().decoded.portnum), + static_cast(packet_queue_.back().decoded.request_id), + static_cast(packet_queue_.back().from), + static_cast(packet_queue_.back().to), + static_cast(packet_queue_.back().decoded.payload.size)); notifyFromNum(packet_queue_.back().id); } @@ -302,7 +643,8 @@ bool MeshtasticPhoneCore::handleToRadio(const uint8_t* data, size_t len) std::memcpy(last_to_radio_, data, len); last_to_radio_len_ = len; - meshtastic_ToRadio to_radio = meshtastic_ToRadio_init_zero; + auto& to_radio = to_radio_scratch_; + std::memset(&to_radio, 0, sizeof(to_radio)); pb_istream_t stream = pb_istream_from_buffer(data, len); if (!pb_decode(&stream, meshtastic_ToRadio_fields, &to_radio)) { @@ -320,6 +662,9 @@ bool MeshtasticPhoneCore::handleToRadio(const uint8_t* data, size_t len) case meshtastic_ToRadio_mqttClientProxyMessage_tag: return hooks_ ? hooks_->handleMqttProxyToRadio(to_radio.mqttClientProxyMessage) : false; case meshtastic_ToRadio_want_config_id_tag: + logDual("[BLE][mtcore][flow] want_config nonce=%08lX stage=%s\n", + static_cast(to_radio.want_config_id), + configStageName(to_radio.want_config_id)); enqueueConfigSnapshot(to_radio.want_config_id); return true; case meshtastic_ToRadio_heartbeat_tag: @@ -348,16 +693,11 @@ bool MeshtasticPhoneCore::handleToRadioPacket(meshtastic_MeshPacket& packet) packet.from = ctx_.getSelfNodeId(); packet.rx_time = nowSeconds(); - - const bool is_broadcast = (packet.to == 0 || packet.to == 0xFFFFFFFFUL); - if (is_broadcast) - { - // Meshtastic broadcast messages should not be modeled as ACKed sends. - // Some phone clients set want_ack by default, which leaves the UI waiting - // for an ACK that will never exist for broadcast traffic. - packet.want_ack = false; - packet.decoded.want_response = false; - } + logDual("[BLE][mtcore] packet port=%u to=%08lX want_resp=%u len=%u\n", + static_cast(packet.decoded.portnum), + static_cast(packet.to), + packet.decoded.want_response ? 1U : 0U, + static_cast(packet.decoded.payload.size)); const bool admin_for_self = (packet.decoded.portnum == meshtastic_PortNum_ADMIN_APP) && @@ -406,14 +746,16 @@ bool MeshtasticPhoneCore::handleToRadioPacket(meshtastic_MeshPacket& packet) bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) { - meshtastic_AdminMessage req = meshtastic_AdminMessage_init_zero; + std::memset(&admin_req_scratch_, 0, sizeof(admin_req_scratch_)); pb_istream_t stream = pb_istream_from_buffer(packet.decoded.payload.bytes, packet.decoded.payload.size); - if (!pb_decode(&stream, meshtastic_AdminMessage_fields, &req)) + if (!pb_decode(&stream, meshtastic_AdminMessage_fields, &admin_req_scratch_)) { return false; } - meshtastic_AdminMessage resp = meshtastic_AdminMessage_init_zero; + std::memset(&admin_resp_scratch_, 0, sizeof(admin_resp_scratch_)); + meshtastic_AdminMessage& req = admin_req_scratch_; + meshtastic_AdminMessage& resp = admin_resp_scratch_; bool has_resp = false; auto& cfg = ctx_.getConfig(); @@ -574,6 +916,25 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) } ctx_.setBleEnabled(req.set_config.payload_variant.bluetooth.enabled); break; + case meshtastic_Config_device_tag: + { + const char* tzdef = req.set_config.payload_variant.device.tzdef; + if (tzdef[0] != '\0') + { + int offset_min = 0; + if (parsePosixTzOffsetMinutes(tzdef, &offset_min)) + { + ::platform::ui::time::set_timezone_offset_min(offset_min); + saveStoredTimezoneTzdef(tzdef); + logDual("[BLE][mtcore] set_config device tzdef=%s offset_min=%d\n", tzdef, offset_min); + } + else + { + logDual("[BLE][mtcore] set_config device tzdef parse failed tzdef=%s\n", tzdef); + } + } + break; + } case meshtastic_Config_device_ui_tag: break; case meshtastic_Config_display_tag: @@ -590,6 +951,10 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) { resp.get_config_response = buildConfig(meshtastic_AdminMessage_ConfigType_BLUETOOTH_CONFIG); } + else if (req.set_config.which_payload_variant == meshtastic_Config_device_tag) + { + resp.get_config_response = buildConfig(meshtastic_AdminMessage_ConfigType_DEVICE_CONFIG); + } else if (req.set_config.which_payload_variant == meshtastic_Config_display_tag) { resp.get_config_response = buildConfig(meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG); @@ -616,6 +981,7 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) req.set_module_config.payload_variant.mqtt.root); module_config_.has_mqtt = true; module_config_.mqtt = req.set_module_config.payload_variant.mqtt; + applyLegacyMqttDefaults(&module_config_); break; case meshtastic_ModuleConfig_serial_tag: module_config_.has_serial = true; @@ -740,7 +1106,8 @@ bool MeshtasticPhoneCore::handleAdmin(meshtastic_MeshPacket& packet) return true; } - meshtastic_MeshPacket reply = meshtastic_MeshPacket_init_zero; + auto& reply = reply_packet_scratch_; + std::memset(&reply, 0, sizeof(reply)); reply.from = ctx_.getSelfNodeId(); reply.to = ctx_.getSelfNodeId(); reply.channel = packet.channel; @@ -801,7 +1168,8 @@ bool MeshtasticPhoneCore::handleLocalSelfPacket(meshtastic_MeshPacket& packet) return false; } - meshtastic_MeshPacket reply = meshtastic_MeshPacket_init_zero; + auto& reply = reply_packet_scratch_; + std::memset(&reply, 0, sizeof(reply)); reply.from = self; reply.to = self; reply.channel = packet.channel; @@ -827,10 +1195,42 @@ bool MeshtasticPhoneCore::handleLocalSelfPacket(meshtastic_MeshPacket& packet) return true; } + if (packet.decoded.portnum == meshtastic_PortNum_POSITION_APP && packet.decoded.want_response) + { + auto& reply = reply_packet_scratch_; + std::memset(&reply, 0, sizeof(reply)); + reply.from = self; + reply.to = self; + reply.channel = packet.channel; + reply.id = static_cast(millis()); + reply.rx_time = nowSeconds(); + reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + reply.decoded = meshtastic_Data_init_zero; + reply.decoded.portnum = meshtastic_PortNum_POSITION_APP; + reply.decoded.dest = self; + reply.decoded.source = self; + reply.decoded.request_id = packet.id; + reply.decoded.want_response = false; + reply.decoded.has_bitfield = true; + reply.decoded.bitfield = 0; + size_t payload_len = sizeof(reply.decoded.payload.bytes); + if (!buildSelfPositionPayload(reply.decoded.payload.bytes, &payload_len)) + { + logDual("[BLE][mtcore] self position unavailable, skip loopback tx req=%08lX\n", + static_cast(packet.id)); + return true; + } + reply.decoded.payload.size = static_cast(payload_len); + packet_queue_.push_back(reply); + notifyFromNum(reply.id); + return true; + } + if (packet.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && packet.decoded.want_response) { meshtastic_NodeInfo self_info = buildSelfNodeInfo(); - meshtastic_MeshPacket reply = meshtastic_MeshPacket_init_zero; + auto& reply = reply_packet_scratch_; + std::memset(&reply, 0, sizeof(reply)); reply.from = self; reply.to = self; reply.channel = packet.channel; @@ -856,6 +1256,17 @@ bool MeshtasticPhoneCore::handleLocalSelfPacket(meshtastic_MeshPacket& packet) return true; } + if (packet.decoded.portnum != meshtastic_PortNum_TEXT_MESSAGE_APP && + packet.decoded.portnum != meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) + { + logDual("[BLE][mtcore] suppress self loopback port=%u want_resp=%u req=%08lX len=%u\n", + static_cast(packet.decoded.portnum), + packet.decoded.want_response ? 1U : 0U, + static_cast(packet.id), + static_cast(packet.decoded.payload.size)); + return !packet.decoded.want_response; + } + return false; } @@ -888,10 +1299,12 @@ bool MeshtasticPhoneCore::popToPhone(MeshtasticBleFrame* out) if (hooks_) { - meshtastic_MqttClientProxyMessage mqtt = meshtastic_MqttClientProxyMessage_init_zero; + auto& mqtt = mqtt_proxy_scratch_; + std::memset(&mqtt, 0, sizeof(mqtt)); if (hooks_->pollMqttProxyToPhone(&mqtt)) { - meshtastic_FromRadio from = meshtastic_FromRadio_init_zero; + auto& from = from_radio_scratch_; + std::memset(&from, 0, sizeof(from)); from.which_payload_variant = meshtastic_FromRadio_mqttClientProxyMessage_tag; from.mqttClientProxyMessage = mqtt; return encodeFromRadio(from, 0, out); @@ -910,7 +1323,15 @@ bool MeshtasticPhoneCore::popToPhone(MeshtasticBleFrame* out) return true; } - meshtastic_FromRadio from = meshtastic_FromRadio_init_zero; + if (config_drain_empty_pending_) + { + config_drain_empty_pending_ = false; + logDual("[BLE][mtcore] config snapshot drain-empty\n"); + return false; + } + + auto& from = from_radio_scratch_; + std::memset(&from, 0, sizeof(from)); if (!queue_status_queue_.empty()) { from.which_payload_variant = meshtastic_FromRadio_queueStatus_tag; @@ -937,30 +1358,43 @@ bool MeshtasticPhoneCore::popConfigSnapshotFrame(MeshtasticBleFrame* out) return false; } - meshtastic_FromRadio from = meshtastic_FromRadio_init_zero; - uint32_t from_num = config_nonce_; + auto& from = from_radio_scratch_; + std::memset(&from, 0, sizeof(from)); + const uint32_t from_num = config_nonce_; if (config_node_index_ == 0) { from.which_payload_variant = meshtastic_FromRadio_my_info_tag; - from.my_info = buildMyInfo(); + fillMyInfo(&from.my_info); ++config_node_index_; + logDual("[BLE][mtcore][cfg#%lu] frame my_info nonce=%08lX\n", + static_cast(config_request_seq_), + static_cast(from_num)); + logRuntimeFootprint("cfg_my_info"); return encodeFromRadio(from, from_num, out); } if (config_node_index_ == 1) { from.which_payload_variant = meshtastic_FromRadio_deviceuiConfig_tag; - from.deviceuiConfig = buildDeviceUi(); + fillDeviceUi(&from.deviceuiConfig); ++config_node_index_; + logDual("[BLE][mtcore][cfg#%lu] frame deviceui nonce=%08lX\n", + static_cast(config_request_seq_), + static_cast(from_num)); + logRuntimeFootprint("cfg_deviceui"); return encodeFromRadio(from, from_num, out); } if (config_node_index_ == 2) { from.which_payload_variant = meshtastic_FromRadio_node_info_tag; - from.node_info = buildSelfNodeInfo(); + fillSelfNodeInfo(&from.node_info); ++config_node_index_; + logDual("[BLE][mtcore][cfg#%lu] frame self_node nonce=%08lX\n", + static_cast(config_request_seq_), + static_cast(from_num)); + logRuntimeFootprint("cfg_self_node"); return encodeFromRadio(from, from_num, out); } @@ -976,7 +1410,7 @@ bool MeshtasticPhoneCore::popConfigSnapshotFrame(MeshtasticBleFrame* out) continue; } from.which_payload_variant = meshtastic_FromRadio_node_info_tag; - from.node_info = buildNodeInfoFromEntry(entry); + fillNodeInfoFromEntry(entry, &from.node_info); return encodeFromRadio(from, entry.node_id, out); } } @@ -984,16 +1418,16 @@ bool MeshtasticPhoneCore::popConfigSnapshotFrame(MeshtasticBleFrame* out) if (config_channel_index_ == 0) { from.which_payload_variant = meshtastic_FromRadio_metadata_tag; - from.metadata = buildMetadata(); + fillMetadata(&from.metadata); ++config_channel_index_; return encodeFromRadio(from, from_num, out); } const uint8_t channel_slot = static_cast(config_channel_index_ - 1); - if (channel_slot < kMaxMeshtasticChannels) + if (channel_slot < meshtastic_defaults::kMaxMeshtasticChannels) { from.which_payload_variant = meshtastic_FromRadio_channel_tag; - from.channel = buildChannel(channel_slot); + fillChannel(channel_slot, &from.channel); ++config_channel_index_; return encodeFromRadio(from, from_num, out); } @@ -1001,20 +1435,25 @@ bool MeshtasticPhoneCore::popConfigSnapshotFrame(MeshtasticBleFrame* out) if (config_type_index_ < (sizeof(kConfigSnapshotTypes) / sizeof(kConfigSnapshotTypes[0]))) { from.which_payload_variant = meshtastic_FromRadio_config_tag; - from.config = buildConfig(kConfigSnapshotTypes[config_type_index_++]); + fillConfig(kConfigSnapshotTypes[config_type_index_++], &from.config); return encodeFromRadio(from, from_num, out); } if (config_module_type_index_ < (sizeof(kModuleSnapshotTypes) / sizeof(kModuleSnapshotTypes[0]))) { from.which_payload_variant = meshtastic_FromRadio_moduleConfig_tag; - from.moduleConfig = buildModuleConfig(kModuleSnapshotTypes[config_module_type_index_++]); + fillModuleConfig(kModuleSnapshotTypes[config_module_type_index_++], &from.moduleConfig); return encodeFromRadio(from, from_num, out); } from.which_payload_variant = meshtastic_FromRadio_config_complete_id_tag; from.config_complete_id = config_nonce_; + const size_t completed_node_index = config_node_index_; + const uint8_t completed_channel_index = config_channel_index_; + const uint8_t completed_config_index = config_type_index_; + const uint8_t completed_module_index = config_module_type_index_; config_flow_active_ = false; + config_drain_empty_pending_ = true; config_nonce_ = 0; config_node_index_ = 0; config_channel_index_ = 0; @@ -1024,6 +1463,17 @@ bool MeshtasticPhoneCore::popConfigSnapshotFrame(MeshtasticBleFrame* out) { hooks_->onConfigComplete(); } + logDual("[BLE][mtcore][cfg#%lu] complete nonce=%08lX nodes=%u channels=%u configs=%u modules=%u\n", + static_cast(config_request_seq_), + static_cast(from_num), + static_cast(completed_node_index), + static_cast(completed_channel_index), + static_cast(completed_config_index), + static_cast(completed_module_index)); + logDual("[BLE][mtcore][flow] cfg_complete stage=%s nonce=%08lX\n", + configStageName(from_num), + static_cast(from_num)); + logRuntimeFootprint("cfg_complete"); return encodeFromRadio(from, from_num, out); } @@ -1031,18 +1481,27 @@ bool MeshtasticPhoneCore::encodeFromRadio(const meshtastic_FromRadio& from, uint { if (!out) { + logDual("[BLE][mtcore] encode from_radio failed: out=null variant=%u from_num=%08lX\n", + static_cast(from.which_payload_variant), + static_cast(from_num)); return false; } - meshtastic_FromRadio msg = from; pb_ostream_t ostream = pb_ostream_from_buffer(out->buf, sizeof(out->buf)); - if (!pb_encode(&ostream, meshtastic_FromRadio_fields, &msg)) + if (!pb_encode(&ostream, meshtastic_FromRadio_fields, const_cast(&from))) { + logDual("[BLE][mtcore] encode from_radio failed: variant=%u from_num=%08lX\n", + static_cast(from.which_payload_variant), + static_cast(from_num)); return false; } out->len = ostream.bytes_written; out->from_num = from_num; + logDual("[BLE][mtcore] encode from_radio ok: variant=%u from_num=%08lX len=%u\n", + static_cast(from.which_payload_variant), + static_cast(from_num), + static_cast(out->len)); return true; } @@ -1054,11 +1513,16 @@ void MeshtasticPhoneCore::enqueueQueueStatus(uint32_t packet_id, bool ok) status.maxlen = kQueueDepthHint; status.mesh_packet_id = packet_id; queue_status_queue_.push_back(status); + logDual("[BLE][mtcore] queue status mesh_packet_id=%08lX ok=%u depth=%u\n", + static_cast(packet_id), + ok ? 1U : 0U, + static_cast(queue_status_queue_.size())); notifyFromNum(packet_id); } void MeshtasticPhoneCore::enqueueConfigSnapshot(uint32_t config_nonce) { + ++config_request_seq_; config_flow_active_ = true; config_nonce_ = config_nonce; config_node_index_ = 0; @@ -1070,6 +1534,13 @@ void MeshtasticPhoneCore::enqueueConfigSnapshot(uint32_t config_nonce) { hooks_->onConfigStart(); } + logDual("[BLE][mtcore][cfg#%lu] start nonce=%08lX\n", + static_cast(config_request_seq_), + static_cast(config_nonce)); + logDual("[BLE][mtcore][flow] cfg_start stage=%s nonce=%08lX\n", + configStageName(config_nonce), + static_cast(config_nonce)); + logRuntimeFootprint("cfg_start"); notifyFromNum(config_nonce); } @@ -1087,12 +1558,17 @@ void MeshtasticPhoneCore::notifyFromNum(uint32_t from_num) transport_.notifyFromNum(from_num); } -meshtastic_MyNodeInfo MeshtasticPhoneCore::buildMyInfo() const +void MeshtasticPhoneCore::fillMyInfo(meshtastic_MyNodeInfo* out) const { - meshtastic_MyNodeInfo info = meshtastic_MyNodeInfo_init_zero; + if (!out) + { + return; + } + meshtastic_MyNodeInfo& info = *out; + std::memset(&info, 0, sizeof(info)); info.my_node_num = ctx_.getSelfNodeId(); info.reboot_count = 0; - info.min_app_version = kOfficialMinAppVersion; + info.min_app_version = meshtastic_defaults::kOfficialMinAppVersion; size_t nodedb_count = 1; if (const auto* store = ctx_.getNodeStore()) @@ -1114,12 +1590,23 @@ meshtastic_MyNodeInfo MeshtasticPhoneCore::buildMyInfo() const copyBounded(info.pio_env, sizeof(info.pio_env), "Trail Mate"); info.firmware_edition = meshtastic_FirmwareEdition_VANILLA; +} + +meshtastic_MyNodeInfo MeshtasticPhoneCore::buildMyInfo() const +{ + meshtastic_MyNodeInfo info = meshtastic_MyNodeInfo_init_zero; + fillMyInfo(&info); return info; } -meshtastic_NodeInfo MeshtasticPhoneCore::buildSelfNodeInfo() const +void MeshtasticPhoneCore::fillSelfNodeInfo(meshtastic_NodeInfo* out) const { - meshtastic_NodeInfo info = meshtastic_NodeInfo_init_zero; + if (!out) + { + return; + } + meshtastic_NodeInfo& info = *out; + std::memset(&info, 0, sizeof(info)); info.num = ctx_.getSelfNodeId(); info.has_user = true; @@ -1138,12 +1625,23 @@ meshtastic_NodeInfo MeshtasticPhoneCore::buildSelfNodeInfo() const info.last_heard = nowSeconds(); info.has_hops_away = true; info.hops_away = 0; +} + +meshtastic_NodeInfo MeshtasticPhoneCore::buildSelfNodeInfo() const +{ + meshtastic_NodeInfo info = meshtastic_NodeInfo_init_zero; + fillSelfNodeInfo(&info); return info; } -meshtastic_NodeInfo MeshtasticPhoneCore::buildNodeInfoFromEntry(const chat::contacts::NodeEntry& entry) const +void MeshtasticPhoneCore::fillNodeInfoFromEntry(const chat::contacts::NodeEntry& entry, meshtastic_NodeInfo* out) const { - meshtastic_NodeInfo info = meshtastic_NodeInfo_init_zero; + if (!out) + { + return; + } + meshtastic_NodeInfo& info = *out; + std::memset(&info, 0, sizeof(info)); info.num = entry.node_id; info.has_user = true; @@ -1152,6 +1650,10 @@ meshtastic_NodeInfo MeshtasticPhoneCore::buildNodeInfoFromEntry(const chat::cont copyBounded(info.user.id, sizeof(info.user.id), user_id); copyBounded(info.user.long_name, sizeof(info.user.long_name), entry.long_name); copyBounded(info.user.short_name, sizeof(info.user.short_name), entry.short_name); + if (entry.has_macaddr) + { + memcpy(info.user.macaddr, entry.macaddr, sizeof(info.user.macaddr)); + } info.user.hw_model = static_cast(entry.hw_model); info.user.role = roleFromEntry(entry.role); info.channel = entry.channel; @@ -1159,6 +1661,23 @@ meshtastic_NodeInfo MeshtasticPhoneCore::buildNodeInfoFromEntry(const chat::cont info.snr = entry.snr; info.has_hops_away = (entry.hops_away != 0xFFU); info.hops_away = entry.hops_away; + info.via_mqtt = entry.via_mqtt; + info.is_ignored = entry.is_ignored; + info.is_key_manually_verified = entry.key_manually_verified; + if (entry.has_device_metrics) + { + info.has_device_metrics = true; + info.device_metrics.has_battery_level = entry.device_metrics.has_battery_level; + info.device_metrics.battery_level = entry.device_metrics.battery_level; + info.device_metrics.has_voltage = entry.device_metrics.has_voltage; + info.device_metrics.voltage = entry.device_metrics.voltage; + info.device_metrics.has_channel_utilization = entry.device_metrics.has_channel_utilization; + info.device_metrics.channel_utilization = entry.device_metrics.channel_utilization; + info.device_metrics.has_air_util_tx = entry.device_metrics.has_air_util_tx; + info.device_metrics.air_util_tx = entry.device_metrics.air_util_tx; + info.device_metrics.has_uptime_seconds = entry.device_metrics.has_uptime_seconds; + info.device_metrics.uptime_seconds = entry.device_metrics.uptime_seconds; + } const chat::contacts::NodeInfo* node = ctx_.getContactService().getNodeInfo(entry.node_id); if (node != nullptr) @@ -1181,15 +1700,25 @@ meshtastic_NodeInfo MeshtasticPhoneCore::buildNodeInfoFromEntry(const chat::cont info.position.gps_accuracy = node->position.gps_accuracy_mm; } } +} +meshtastic_NodeInfo MeshtasticPhoneCore::buildNodeInfoFromEntry(const chat::contacts::NodeEntry& entry) const +{ + meshtastic_NodeInfo info = meshtastic_NodeInfo_init_zero; + fillNodeInfoFromEntry(entry, &info); return info; } -meshtastic_DeviceMetadata MeshtasticPhoneCore::buildMetadata() const +void MeshtasticPhoneCore::fillMetadata(meshtastic_DeviceMetadata* out) const { - meshtastic_DeviceMetadata metadata = meshtastic_DeviceMetadata_init_zero; - copyBounded(metadata.firmware_version, sizeof(metadata.firmware_version), kCompatFirmwareVersion); - metadata.device_state_version = kOfficialDeviceStateVersion; + if (!out) + { + return; + } + meshtastic_DeviceMetadata& metadata = *out; + std::memset(&metadata, 0, sizeof(metadata)); + copyBounded(metadata.firmware_version, sizeof(metadata.firmware_version), meshtastic_defaults::kCompatFirmwareVersion); + metadata.device_state_version = meshtastic_defaults::kOfficialDeviceStateVersion; metadata.canShutdown = true; metadata.hasBluetooth = true; metadata.hasWifi = false; @@ -1208,6 +1737,12 @@ meshtastic_DeviceMetadata MeshtasticPhoneCore::buildMetadata() const metadata.position_flags = 0; metadata.hw_model = meshtastic_HardwareModel_UNSET; metadata.excluded_modules = 0; +} + +meshtastic_DeviceMetadata MeshtasticPhoneCore::buildMetadata() const +{ + meshtastic_DeviceMetadata metadata = meshtastic_DeviceMetadata_init_zero; + fillMetadata(&metadata); return metadata; } @@ -1243,9 +1778,14 @@ meshtastic_LocalStats MeshtasticPhoneCore::buildLocalStats() const return stats; } -meshtastic_DeviceUIConfig MeshtasticPhoneCore::buildDeviceUi() const +void MeshtasticPhoneCore::fillDeviceUi(meshtastic_DeviceUIConfig* out) const { - meshtastic_DeviceUIConfig ui = meshtastic_DeviceUIConfig_init_zero; + if (!out) + { + return; + } + meshtastic_DeviceUIConfig& ui = *out; + std::memset(&ui, 0, sizeof(ui)); ui.version = 1; ui.screen_brightness = 255; ui.screen_timeout = 30; @@ -1264,12 +1804,23 @@ meshtastic_DeviceUIConfig MeshtasticPhoneCore::buildDeviceUi() const ui.screen_rgb_color = 0; ui.is_clockface_analog = false; ui.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC; +} + +meshtastic_DeviceUIConfig MeshtasticPhoneCore::buildDeviceUi() const +{ + meshtastic_DeviceUIConfig ui = meshtastic_DeviceUIConfig_init_zero; + fillDeviceUi(&ui); return ui; } -meshtastic_Channel MeshtasticPhoneCore::buildChannel(uint8_t idx) const +void MeshtasticPhoneCore::fillChannel(uint8_t idx, meshtastic_Channel* out) const { - meshtastic_Channel channel = meshtastic_Channel_init_zero; + if (!out) + { + return; + } + meshtastic_Channel& channel = *out; + std::memset(&channel, 0, sizeof(channel)); channel.index = idx; const auto& cfg = ctx_.getConfig(); @@ -1292,7 +1843,7 @@ meshtastic_Channel MeshtasticPhoneCore::buildChannel(uint8_t idx) const if (!enabled) { channel.has_settings = false; - return channel; + return; } channel.has_settings = true; @@ -1333,174 +1884,205 @@ meshtastic_Channel MeshtasticPhoneCore::buildChannel(uint8_t idx) const sizeof(cfg.meshtastic_config.secondary_key)); } } +} + +meshtastic_Channel MeshtasticPhoneCore::buildChannel(uint8_t idx) const +{ + meshtastic_Channel channel = meshtastic_Channel_init_zero; + fillChannel(idx, &channel); return channel; } +void MeshtasticPhoneCore::fillConfig(meshtastic_AdminMessage_ConfigType type, meshtastic_Config* out) const +{ + if (!out) + { + return; + } + const auto& cfg = ctx_.getConfig(); + std::memset(out, 0, sizeof(*out)); + meshtastic_Config& cfg_out = *out; + switch (type) + { + case meshtastic_AdminMessage_ConfigType_DEVICE_CONFIG: + cfg_out.which_payload_variant = meshtastic_Config_device_tag; + { + meshtastic_Config_DeviceConfig device = meshtastic_Config_DeviceConfig_init_zero; + cfg_out.payload_variant.device = device; + } + cfg_out.payload_variant.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + cfg_out.payload_variant.device.rebroadcast_mode = + cfg.chat_policy.enable_relay ? meshtastic_Config_DeviceConfig_RebroadcastMode_ALL + : meshtastic_Config_DeviceConfig_RebroadcastMode_NONE; + cfg_out.payload_variant.device.node_info_broadcast_secs = 900; + cfg_out.payload_variant.device.serial_enabled = false; + cfg_out.payload_variant.device.is_managed = false; + cfg_out.payload_variant.device.led_heartbeat_disabled = false; + cfg_out.payload_variant.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED; + buildLinkedTimezoneTzdef(cfg_out.payload_variant.device.tzdef, + sizeof(cfg_out.payload_variant.device.tzdef)); + break; + case meshtastic_AdminMessage_ConfigType_POSITION_CONFIG: + cfg_out.which_payload_variant = meshtastic_Config_position_tag; + { + meshtastic_Config_PositionConfig position = meshtastic_Config_PositionConfig_init_zero; + cfg_out.payload_variant.position = position; + } + cfg_out.payload_variant.position.position_broadcast_secs = 900; + cfg_out.payload_variant.position.gps_enabled = (cfg.gps_mode != 0); + cfg_out.payload_variant.position.gps_update_interval = cfg.gps_interval_ms / 1000U; + cfg_out.payload_variant.position.gps_mode = (cfg.gps_mode != 0) + ? meshtastic_Config_PositionConfig_GpsMode_ENABLED + : meshtastic_Config_PositionConfig_GpsMode_DISABLED; + break; + case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG: + cfg_out.which_payload_variant = meshtastic_Config_display_tag; + { + meshtastic_Config_DisplayConfig display = meshtastic_Config_DisplayConfig_init_zero; + cfg_out.payload_variant.display = display; + } + cfg_out.payload_variant.display.screen_on_secs = 30; + cfg_out.payload_variant.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; + cfg_out.payload_variant.display.oled = meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; + cfg_out.payload_variant.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT; + cfg_out.payload_variant.display.compass_orientation = meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0; + break; + case meshtastic_AdminMessage_ConfigType_LORA_CONFIG: + cfg_out.which_payload_variant = meshtastic_Config_lora_tag; + { + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero; + cfg_out.payload_variant.lora = lora; + } + cfg_out.payload_variant.lora.use_preset = cfg.meshtastic_config.use_preset; + cfg_out.payload_variant.lora.modem_preset = + static_cast(cfg.meshtastic_config.modem_preset); + cfg_out.payload_variant.lora.bandwidth = static_cast(cfg.meshtastic_config.bandwidth_khz); + cfg_out.payload_variant.lora.spread_factor = cfg.meshtastic_config.spread_factor; + cfg_out.payload_variant.lora.coding_rate = cfg.meshtastic_config.coding_rate; + cfg_out.payload_variant.lora.frequency_offset = cfg.meshtastic_config.frequency_offset_mhz; + cfg_out.payload_variant.lora.region = + static_cast(cfg.meshtastic_config.region); + cfg_out.payload_variant.lora.hop_limit = cfg.meshtastic_config.hop_limit; + cfg_out.payload_variant.lora.tx_enabled = cfg.meshtastic_config.tx_enabled; + cfg_out.payload_variant.lora.tx_power = cfg.meshtastic_config.tx_power; + cfg_out.payload_variant.lora.channel_num = cfg.meshtastic_config.channel_num; + cfg_out.payload_variant.lora.override_duty_cycle = cfg.meshtastic_config.override_duty_cycle; + cfg_out.payload_variant.lora.override_frequency = cfg.meshtastic_config.override_frequency_mhz; + cfg_out.payload_variant.lora.ignore_mqtt = cfg.meshtastic_config.ignore_mqtt; + cfg_out.payload_variant.lora.config_ok_to_mqtt = cfg.meshtastic_config.config_ok_to_mqtt; + break; + case meshtastic_AdminMessage_ConfigType_BLUETOOTH_CONFIG: + cfg_out.which_payload_variant = meshtastic_Config_bluetooth_tag; + { + meshtastic_Config_BluetoothConfig bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + cfg_out.payload_variant.bluetooth = bluetooth; + } + cfg_out.payload_variant.bluetooth = bluetooth_config_; + cfg_out.payload_variant.bluetooth.enabled = ctx_.isBleEnabled(); + break; + case meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG: + cfg_out.which_payload_variant = meshtastic_Config_security_tag; + { + meshtastic_Config_SecurityConfig security = meshtastic_Config_SecurityConfig_init_zero; + cfg_out.payload_variant.security = security; + } + cfg_out.payload_variant.security.is_managed = false; + cfg_out.payload_variant.security.serial_enabled = false; + cfg_out.payload_variant.security.debug_log_api_enabled = false; + cfg_out.payload_variant.security.admin_channel_enabled = false; + break; + case meshtastic_AdminMessage_ConfigType_DEVICEUI_CONFIG: + cfg_out.which_payload_variant = meshtastic_Config_device_ui_tag; + fillDeviceUi(&cfg_out.payload_variant.device_ui); + break; + default: + cfg_out.which_payload_variant = meshtastic_Config_device_tag; + { + meshtastic_Config_DeviceConfig device = meshtastic_Config_DeviceConfig_init_zero; + cfg_out.payload_variant.device = device; + } + break; + } +} + meshtastic_Config MeshtasticPhoneCore::buildConfig(meshtastic_AdminMessage_ConfigType type) const { - const auto& cfg = ctx_.getConfig(); meshtastic_Config out = meshtastic_Config_init_zero; + fillConfig(type, &out); + return out; +} + +void MeshtasticPhoneCore::fillModuleConfig(meshtastic_AdminMessage_ModuleConfigType type, meshtastic_ModuleConfig* out) const +{ + if (!out) + { + return; + } + std::memset(out, 0, sizeof(*out)); + meshtastic_ModuleConfig& module = *out; switch (type) { - case meshtastic_AdminMessage_ConfigType_DEVICE_CONFIG: - out.which_payload_variant = meshtastic_Config_device_tag; - { - meshtastic_Config_DeviceConfig device = meshtastic_Config_DeviceConfig_init_zero; - out.payload_variant.device = device; - } - out.payload_variant.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - out.payload_variant.device.rebroadcast_mode = - cfg.chat_policy.enable_relay ? meshtastic_Config_DeviceConfig_RebroadcastMode_ALL - : meshtastic_Config_DeviceConfig_RebroadcastMode_NONE; - out.payload_variant.device.node_info_broadcast_secs = 900; - out.payload_variant.device.serial_enabled = false; - out.payload_variant.device.is_managed = false; - out.payload_variant.device.led_heartbeat_disabled = false; - out.payload_variant.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED; + case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag; + module.payload_variant.mqtt = module_config_.mqtt; break; - case meshtastic_AdminMessage_ConfigType_POSITION_CONFIG: - out.which_payload_variant = meshtastic_Config_position_tag; - { - meshtastic_Config_PositionConfig position = meshtastic_Config_PositionConfig_init_zero; - out.payload_variant.position = position; - } - out.payload_variant.position.position_broadcast_secs = 900; - out.payload_variant.position.gps_enabled = (cfg.gps_mode != 0); - out.payload_variant.position.gps_update_interval = cfg.gps_interval_ms / 1000U; - out.payload_variant.position.gps_mode = (cfg.gps_mode != 0) - ? meshtastic_Config_PositionConfig_GpsMode_ENABLED - : meshtastic_Config_PositionConfig_GpsMode_DISABLED; + case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_serial_tag; + module.payload_variant.serial = module_config_.serial; break; - case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG: - out.which_payload_variant = meshtastic_Config_display_tag; - { - meshtastic_Config_DisplayConfig display = meshtastic_Config_DisplayConfig_init_zero; - out.payload_variant.display = display; - } - out.payload_variant.display.screen_on_secs = 30; - out.payload_variant.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; - out.payload_variant.display.oled = meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; - out.payload_variant.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT; - out.payload_variant.display.compass_orientation = meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0; + case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag; + module.payload_variant.external_notification = module_config_.external_notification; break; - case meshtastic_AdminMessage_ConfigType_LORA_CONFIG: - out.which_payload_variant = meshtastic_Config_lora_tag; - { - meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero; - out.payload_variant.lora = lora; - } - out.payload_variant.lora.use_preset = cfg.meshtastic_config.use_preset; - out.payload_variant.lora.modem_preset = - static_cast(cfg.meshtastic_config.modem_preset); - out.payload_variant.lora.bandwidth = static_cast(cfg.meshtastic_config.bandwidth_khz); - out.payload_variant.lora.spread_factor = cfg.meshtastic_config.spread_factor; - out.payload_variant.lora.coding_rate = cfg.meshtastic_config.coding_rate; - out.payload_variant.lora.frequency_offset = cfg.meshtastic_config.frequency_offset_mhz; - out.payload_variant.lora.region = static_cast(cfg.meshtastic_config.region); - out.payload_variant.lora.hop_limit = cfg.meshtastic_config.hop_limit; - out.payload_variant.lora.tx_enabled = cfg.meshtastic_config.tx_enabled; - out.payload_variant.lora.tx_power = cfg.meshtastic_config.tx_power; - out.payload_variant.lora.channel_num = cfg.meshtastic_config.channel_num; - out.payload_variant.lora.override_duty_cycle = cfg.meshtastic_config.override_duty_cycle; - out.payload_variant.lora.override_frequency = cfg.meshtastic_config.override_frequency_mhz; - out.payload_variant.lora.ignore_mqtt = cfg.meshtastic_config.ignore_mqtt; - out.payload_variant.lora.config_ok_to_mqtt = cfg.meshtastic_config.config_ok_to_mqtt; + case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag; + module.payload_variant.store_forward = module_config_.store_forward; break; - case meshtastic_AdminMessage_ConfigType_BLUETOOTH_CONFIG: - out.which_payload_variant = meshtastic_Config_bluetooth_tag; - { - meshtastic_Config_BluetoothConfig bluetooth = meshtastic_Config_BluetoothConfig_init_zero; - out.payload_variant.bluetooth = bluetooth; - } - out.payload_variant.bluetooth = bluetooth_config_; - out.payload_variant.bluetooth.enabled = ctx_.isBleEnabled(); + case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_range_test_tag; + module.payload_variant.range_test = module_config_.range_test; break; - case meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG: - out.which_payload_variant = meshtastic_Config_security_tag; - { - meshtastic_Config_SecurityConfig security = meshtastic_Config_SecurityConfig_init_zero; - out.payload_variant.security = security; - } - out.payload_variant.security.is_managed = false; - out.payload_variant.security.serial_enabled = false; - out.payload_variant.security.debug_log_api_enabled = false; - out.payload_variant.security.admin_channel_enabled = false; + case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag; + module.payload_variant.telemetry = module_config_.telemetry; break; - case meshtastic_AdminMessage_ConfigType_DEVICEUI_CONFIG: - out.which_payload_variant = meshtastic_Config_device_ui_tag; - out.payload_variant.device_ui = buildDeviceUi(); + case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag; + module.payload_variant.canned_message = module_config_.canned_message; + break; + case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_audio_tag; + module.payload_variant.audio = module_config_.audio; + break; + case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; + module.payload_variant.remote_hardware = module_config_.remote_hardware; + break; + case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag; + module.payload_variant.neighbor_info = module_config_.neighbor_info; + break; + case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag; + module.payload_variant.ambient_lighting = module_config_.ambient_lighting; + break; + case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag; + module.payload_variant.detection_sensor = module_config_.detection_sensor; + break; + case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG: + module.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; + module.payload_variant.paxcounter = module_config_.paxcounter; break; default: - out.which_payload_variant = meshtastic_Config_device_tag; - { - meshtastic_Config_DeviceConfig device = meshtastic_Config_DeviceConfig_init_zero; - out.payload_variant.device = device; - } break; } - return out; } meshtastic_ModuleConfig MeshtasticPhoneCore::buildModuleConfig(meshtastic_AdminMessage_ModuleConfigType type) const { meshtastic_ModuleConfig out = meshtastic_ModuleConfig_init_zero; - switch (type) - { - case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag; - out.payload_variant.mqtt = module_config_.mqtt; - break; - case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_serial_tag; - out.payload_variant.serial = module_config_.serial; - break; - case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag; - out.payload_variant.external_notification = module_config_.external_notification; - break; - case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag; - out.payload_variant.store_forward = module_config_.store_forward; - break; - case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_range_test_tag; - out.payload_variant.range_test = module_config_.range_test; - break; - case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag; - out.payload_variant.telemetry = module_config_.telemetry; - break; - case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag; - out.payload_variant.canned_message = module_config_.canned_message; - break; - case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_audio_tag; - out.payload_variant.audio = module_config_.audio; - break; - case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; - out.payload_variant.remote_hardware = module_config_.remote_hardware; - break; - case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag; - out.payload_variant.neighbor_info = module_config_.neighbor_info; - break; - case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag; - out.payload_variant.ambient_lighting = module_config_.ambient_lighting; - break; - case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag; - out.payload_variant.detection_sensor = module_config_.detection_sensor; - break; - case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG: - out.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; - out.payload_variant.paxcounter = module_config_.paxcounter; - break; - default: - break; - } + fillModuleConfig(type, &out); return out; } @@ -1538,7 +2120,20 @@ meshtastic_MeshPacket MeshtasticPhoneCore::buildPacketFromData(const chat::MeshI packet.from = msg.from; packet.to = msg.to; packet.channel = channelIndexFromId(msg.channel); - packet.id = (msg.packet_id == 0) ? static_cast(millis()) : msg.packet_id; + if (msg.packet_id != 0) + { + packet.id = msg.packet_id; + } + else if (msg.portnum == meshtastic_PortNum_ROUTING_APP && msg.request_id != 0) + { + // Keep synthetic routing/ack packets tied to the original request ID so + // Meshtastic phone clients can correlate them with the pending send. + packet.id = msg.request_id; + } + else + { + packet.id = static_cast(millis()); + } packet.rx_time = (msg.rx_meta.rx_timestamp_s != 0) ? msg.rx_meta.rx_timestamp_s : nowSeconds(); packet.rx_snr = msg.rx_meta.snr_db_x10 / 10.0f; packet.rx_rssi = msg.rx_meta.rssi_dbm_x10 / 10; diff --git a/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp b/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp index cb8e9f53..ab730c52 100644 --- a/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp +++ b/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp @@ -64,6 +64,57 @@ bool encodeTextMessage(ChannelId channel, const std::string& text, return true; } +bool decodeTextPayload(const meshtastic_Data& data, MeshIncomingText* out) +{ + if (!out) + { + return false; + } + + if (data.portnum != meshtastic_PortNum_TEXT_MESSAGE_APP && + data.portnum != meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) + { + return false; + } + + if (data.payload.size == 0 || data.payload.size > sizeof(data.payload.bytes)) + { + return false; + } + + if (data.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) + { + char decompressed[256] = {}; + const int out_len = unishox2_decompress( + reinterpret_cast(data.payload.bytes), + static_cast(data.payload.size), + decompressed, + static_cast(sizeof(decompressed) - 1), + USX_PSET_DFLT); + if (out_len <= 0) + { + return false; + } + decompressed[std::min(out_len, static_cast(sizeof(decompressed) - 1))] = '\0'; + out->text.assign(decompressed, static_cast(out_len)); + } + else + { + out->text.assign(reinterpret_cast(data.payload.bytes), data.payload.size); + } + + // Note: from, msg_id, timestamp, channel should be extracted from packet header + // This will be done in the adapter when decoding the full packet. + out->from = 0; + out->msg_id = 0; + out->timestamp = now_message_timestamp(); + out->channel = ChannelId::PRIMARY; + out->hop_limit = 2; + out->encrypted = false; + + return true; +} + bool decodeTextMessage(const uint8_t* buffer, size_t size, MeshIncomingText* out) { if (!buffer || !out || size == 0) @@ -79,47 +130,7 @@ bool decodeTextMessage(const uint8_t* buffer, size_t size, MeshIncomingText* out return false; } - // Check port number - if (data.portnum != meshtastic_PortNum_TEXT_MESSAGE_APP && - data.portnum != meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) - { - return false; - } - - // Extract text (decompress if needed) - if (data.payload.size == 0 || data.payload.size > sizeof(data.payload.bytes)) - { - return false; - } - - if (data.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) - { - char decompressed[256]; - memset(decompressed, 0, sizeof(decompressed)); - int out_len = unishox2_decompress_simple( - reinterpret_cast(data.payload.bytes), - static_cast(data.payload.size), - decompressed); - if (out_len <= 0) - { - return false; - } - out->text.assign(decompressed, static_cast(out_len)); - } - else - { - out->text.assign(reinterpret_cast(data.payload.bytes), data.payload.size); - } - // Note: from, msg_id, timestamp, channel should be extracted from packet header - // This will be done in mt_adapter when decoding full packet - out->from = 0; // Will be set from packet header - out->msg_id = 0; // Will be set from packet header - out->timestamp = now_message_timestamp(); - out->channel = ChannelId::PRIMARY; // Will be set from packet header - out->hop_limit = 2; - out->encrypted = false; - - return true; + return decodeTextPayload(data, out); } bool decodeKeyVerificationMessage(const uint8_t* buffer, size_t size, @@ -265,9 +276,18 @@ bool decodeAppData(const uint8_t* buffer, size_t size, MeshIncomingData* out) return false; } + return decodeAppPayload(data, out); +} + +bool decodeAppPayload(const meshtastic_Data& data, MeshIncomingData* out) +{ + if (!out) + { + return false; + } + if (data.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP || - data.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP || - data.portnum == meshtastic_PortNum_NODEINFO_APP) + data.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) { return false; } diff --git a/modules/core_chat/src/infra/node_store_blob_format.cpp b/modules/core_chat/src/infra/node_store_blob_format.cpp index be536557..0d613b28 100644 --- a/modules/core_chat/src/infra/node_store_blob_format.cpp +++ b/modules/core_chat/src/infra/node_store_blob_format.cpp @@ -14,12 +14,17 @@ namespace contacts bool isValidNodeBlobSize(size_t len) { - return (len % NodeStoreCore::kSerializedEntrySize) == 0 || + return (len % NodeStoreCore::kSerializedEntrySizeV8) == 0 || + (len % NodeStoreCore::kSerializedEntrySize) == 0 || (len % NodeStoreCore::kLegacySerializedEntrySize) == 0; } size_t nodeBlobEntryCount(size_t len) { + if ((len % NodeStoreCore::kSerializedEntrySizeV8) == 0) + { + return len / NodeStoreCore::kSerializedEntrySizeV8; + } if ((len % NodeStoreCore::kSerializedEntrySize) == 0) { return len / NodeStoreCore::kSerializedEntrySize; @@ -29,7 +34,7 @@ size_t nodeBlobEntryCount(size_t len) size_t nodeBlobByteSize(size_t count) { - return count * NodeStoreCore::kSerializedEntrySize; + return count * NodeStoreCore::kSerializedEntrySizeV8; } NodeStoreSdHeader makeNodeStoreSdHeader(const uint8_t* data, size_t len) @@ -57,8 +62,10 @@ NodeBlobValidation validateNodeBlobMetadata(size_t len, uint8_t version, return NodeBlobValidation::InvalidLength; } const size_t entry_size = (version == NodeStoreCore::kPersistVersion) - ? NodeStoreCore::kSerializedEntrySize - : NodeStoreCore::kLegacySerializedEntrySize; + ? NodeStoreCore::kSerializedEntrySizeV8 + : ((version == (NodeStoreCore::kPersistVersion - 1)) + ? NodeStoreCore::kSerializedEntrySize + : NodeStoreCore::kLegacySerializedEntrySize); if ((len % entry_size) != 0) { return NodeBlobValidation::InvalidLength; @@ -107,8 +114,10 @@ NodeBlobValidation validateNodeStoreSdBlob(const NodeStoreSdHeader& header, return header_status; } const size_t entry_size = (header.ver == NodeStoreCore::kPersistVersion) - ? NodeStoreCore::kSerializedEntrySize - : NodeStoreCore::kLegacySerializedEntrySize; + ? NodeStoreCore::kSerializedEntrySizeV8 + : ((header.ver == (NodeStoreCore::kPersistVersion - 1)) + ? NodeStoreCore::kSerializedEntrySize + : NodeStoreCore::kLegacySerializedEntrySize); const size_t expected_bytes = header.count * entry_size; if (expected_bytes != len || !isValidNodeBlobSize(len)) { diff --git a/modules/core_chat/src/infra/node_store_core.cpp b/modules/core_chat/src/infra/node_store_core.cpp index 3a0c299a..ccefcd48 100644 --- a/modules/core_chat/src/infra/node_store_core.cpp +++ b/modules/core_chat/src/infra/node_store_core.cpp @@ -63,6 +63,55 @@ struct PersistedNodeEntryV7 static_assert(sizeof(PersistedNodeEntryV7) == NodeStoreCore::kSerializedEntrySize, "PersistedNodeEntryV7 size changed"); +struct PersistedNodeEntryV8 +{ + uint32_t node_id; + char short_name[10]; + char long_name[32]; + uint32_t last_seen; + float snr; + float rssi; + uint8_t hops_away; + uint8_t channel; + uint8_t next_hop; + uint8_t protocol; + uint8_t role; + uint8_t hw_model; + uint8_t has_macaddr; + uint8_t via_mqtt; + uint8_t is_ignored; + uint8_t has_public_key; + uint8_t key_manually_verified; + uint8_t has_device_metrics; + uint8_t position_valid; + uint8_t position_has_altitude; + uint8_t metrics_has_battery_level; + uint8_t metrics_has_voltage; + uint8_t metrics_has_channel_utilization; + uint8_t metrics_has_air_util_tx; + uint8_t metrics_has_uptime_seconds; + uint8_t reserved[3]; + uint8_t macaddr[6]; + uint8_t reserved_mac[2]; + int32_t position_latitude_i; + int32_t position_longitude_i; + int32_t position_altitude; + uint32_t position_timestamp; + uint32_t position_precision_bits; + uint32_t position_pdop; + uint32_t position_hdop; + uint32_t position_vdop; + uint32_t position_gps_accuracy_mm; + uint32_t metrics_battery_level; + float metrics_voltage; + float metrics_channel_utilization; + float metrics_air_util_tx; + uint32_t metrics_uptime_seconds; +} __attribute__((packed)); + +static_assert(sizeof(PersistedNodeEntryV8) == NodeStoreCore::kSerializedEntrySizeV8, + "PersistedNodeEntryV8 size changed"); + void copyCommonFields(NodeEntry& dst, uint32_t node_id, const char short_name[10], @@ -94,7 +143,7 @@ void copyCommonFields(NodeEntry& dst, dst.hw_model = hw_model; } -void copyIntoPersisted(PersistedNodeEntryV7& dst, const NodeEntry& src) +void copyIntoPersisted(PersistedNodeEntryV8& dst, const NodeEntry& src) { memset(&dst, 0, sizeof(dst)); dst.node_id = src.node_id; @@ -111,6 +160,13 @@ void copyIntoPersisted(PersistedNodeEntryV7& dst, const NodeEntry& src) dst.protocol = src.protocol; dst.role = src.role; dst.hw_model = src.hw_model; + dst.has_macaddr = src.has_macaddr ? 1U : 0U; + memcpy(dst.macaddr, src.macaddr, sizeof(dst.macaddr)); + dst.via_mqtt = src.via_mqtt ? 1U : 0U; + dst.is_ignored = src.is_ignored ? 1U : 0U; + dst.has_public_key = src.has_public_key ? 1U : 0U; + dst.key_manually_verified = src.key_manually_verified ? 1U : 0U; + dst.has_device_metrics = src.has_device_metrics ? 1U : 0U; dst.position_valid = src.position_valid ? 1U : 0U; dst.position_has_altitude = src.position_has_altitude ? 1U : 0U; dst.position_latitude_i = src.position_latitude_i; @@ -122,6 +178,16 @@ void copyIntoPersisted(PersistedNodeEntryV7& dst, const NodeEntry& src) dst.position_hdop = src.position_hdop; dst.position_vdop = src.position_vdop; dst.position_gps_accuracy_mm = src.position_gps_accuracy_mm; + dst.metrics_has_battery_level = src.device_metrics.has_battery_level ? 1U : 0U; + dst.metrics_has_voltage = src.device_metrics.has_voltage ? 1U : 0U; + dst.metrics_has_channel_utilization = src.device_metrics.has_channel_utilization ? 1U : 0U; + dst.metrics_has_air_util_tx = src.device_metrics.has_air_util_tx ? 1U : 0U; + dst.metrics_has_uptime_seconds = src.device_metrics.has_uptime_seconds ? 1U : 0U; + dst.metrics_battery_level = src.device_metrics.battery_level; + dst.metrics_voltage = src.device_metrics.voltage; + dst.metrics_channel_utilization = src.device_metrics.channel_utilization; + dst.metrics_air_util_tx = src.device_metrics.air_util_tx; + dst.metrics_uptime_seconds = src.device_metrics.uptime_seconds; } void copyFromPersisted(NodeEntry& dst, const PersistedNodeEntryV6& src) @@ -169,6 +235,51 @@ void copyFromPersisted(NodeEntry& dst, const PersistedNodeEntryV7& src) dst.position_gps_accuracy_mm = src.position_gps_accuracy_mm; } +void copyFromPersisted(NodeEntry& dst, const PersistedNodeEntryV8& src) +{ + copyCommonFields(dst, + src.node_id, + src.short_name, + src.long_name, + src.last_seen, + src.snr, + src.rssi, + src.hops_away, + src.channel, + src.next_hop, + src.protocol, + src.role, + src.hw_model); + dst.has_macaddr = src.has_macaddr != 0; + memcpy(dst.macaddr, src.macaddr, sizeof(dst.macaddr)); + dst.via_mqtt = src.via_mqtt != 0; + dst.is_ignored = src.is_ignored != 0; + dst.has_public_key = src.has_public_key != 0; + dst.key_manually_verified = src.key_manually_verified != 0; + dst.has_device_metrics = src.has_device_metrics != 0; + dst.position_valid = src.position_valid != 0; + dst.position_has_altitude = src.position_has_altitude != 0; + dst.position_latitude_i = src.position_latitude_i; + dst.position_longitude_i = src.position_longitude_i; + dst.position_altitude = src.position_altitude; + dst.position_timestamp = src.position_timestamp; + dst.position_precision_bits = src.position_precision_bits; + dst.position_pdop = src.position_pdop; + dst.position_hdop = src.position_hdop; + dst.position_vdop = src.position_vdop; + dst.position_gps_accuracy_mm = src.position_gps_accuracy_mm; + dst.device_metrics.has_battery_level = src.metrics_has_battery_level != 0; + dst.device_metrics.battery_level = src.metrics_battery_level; + dst.device_metrics.has_voltage = src.metrics_has_voltage != 0; + dst.device_metrics.voltage = src.metrics_voltage; + dst.device_metrics.has_channel_utilization = src.metrics_has_channel_utilization != 0; + dst.device_metrics.channel_utilization = src.metrics_channel_utilization; + dst.device_metrics.has_air_util_tx = src.metrics_has_air_util_tx != 0; + dst.device_metrics.air_util_tx = src.metrics_air_util_tx; + dst.device_metrics.has_uptime_seconds = src.metrics_has_uptime_seconds != 0; + dst.device_metrics.uptime_seconds = src.metrics_uptime_seconds; +} + } // namespace NodeStoreCore::NodeStoreCore(INodeBlobStore& blob_store) @@ -181,6 +292,11 @@ void NodeStoreCore::setProtectedNodeChecker(std::function checke protected_node_checker_ = std::move(checker); } +void NodeStoreCore::setAutoSaveEnabled(bool enabled) +{ + auto_save_enabled_ = enabled; +} + void NodeStoreCore::begin() { if (!loadEntries()) @@ -191,163 +307,110 @@ void NodeStoreCore::begin() } } -void NodeStoreCore::upsert(uint32_t node_id, const char* short_name, const char* long_name, - uint32_t now_secs, float snr, float rssi, uint8_t protocol, - uint8_t role, uint8_t hops_away, uint8_t hw_model, uint8_t channel) -{ - for (auto& entry : entries_) - { - if (entry.node_id == node_id) - { - if (short_name && short_name[0] != '\0') - { - strncpy(entry.short_name, short_name, sizeof(entry.short_name) - 1); - entry.short_name[sizeof(entry.short_name) - 1] = '\0'; - } - if (long_name && long_name[0] != '\0') - { - strncpy(entry.long_name, long_name, sizeof(entry.long_name) - 1); - entry.long_name[sizeof(entry.long_name) - 1] = '\0'; - } - entry.last_seen = now_secs; - if (!std::isnan(snr)) - { - entry.snr = snr; - } - if (!std::isnan(rssi)) - { - entry.rssi = rssi; - } - if (hops_away != 0xFF) - { - entry.hops_away = hops_away; - } - if (protocol != 0) - { - entry.protocol = protocol; - } - if (role != kNodeRoleUnknown) - { - entry.role = role; - } - if (hw_model != 0) - { - entry.hw_model = hw_model; - } - if (channel != 0xFF) - { - entry.channel = channel; - } - dirty_ = true; - maybeSave(); - return; - } - } - - if (entries_.size() >= kMaxNodes) - { - const size_t eviction_index = selectEvictionIndex(); - if (eviction_index < entries_.size()) - { - entries_.erase(entries_.begin() + static_cast(eviction_index)); - } - } - - NodeEntry entry{}; - entry.node_id = node_id; - if (short_name && short_name[0] != '\0') - { - strncpy(entry.short_name, short_name, sizeof(entry.short_name) - 1); - entry.short_name[sizeof(entry.short_name) - 1] = '\0'; - } - if (long_name && long_name[0] != '\0') - { - strncpy(entry.long_name, long_name, sizeof(entry.long_name) - 1); - entry.long_name[sizeof(entry.long_name) - 1] = '\0'; - } - entry.last_seen = now_secs; - entry.snr = snr; - entry.rssi = rssi; - entry.hops_away = hops_away; - entry.protocol = protocol; - entry.role = (role != kNodeRoleUnknown) ? role : kNodeRoleUnknown; - entry.hw_model = hw_model; - entry.channel = channel; - entries_.push_back(entry); - - dirty_ = true; - maybeSave(); -} - -void NodeStoreCore::updateProtocol(uint32_t node_id, uint8_t protocol, uint32_t now_secs) -{ - if (protocol == 0) - { - return; - } - - for (auto& entry : entries_) - { - if (entry.node_id == node_id) - { - entry.protocol = protocol; - entry.last_seen = now_secs; - dirty_ = true; - maybeSave(); - return; - } - } - - if (entries_.size() >= kMaxNodes) - { - const size_t eviction_index = selectEvictionIndex(); - if (eviction_index < entries_.size()) - { - entries_.erase(entries_.begin() + static_cast(eviction_index)); - } - } - - NodeEntry entry{}; - entry.node_id = node_id; - entry.short_name[0] = '\0'; - entry.long_name[0] = '\0'; - entry.last_seen = now_secs; - entry.snr = std::numeric_limits::quiet_NaN(); - entry.rssi = std::numeric_limits::quiet_NaN(); - entry.hops_away = 0xFF; - entry.channel = 0xFF; - entry.protocol = protocol; - entries_.push_back(entry); - - dirty_ = true; - maybeSave(); -} - -void NodeStoreCore::updatePosition(uint32_t node_id, const NodePosition& position) +void NodeStoreCore::applyUpdate(uint32_t node_id, const NodeUpdate& update) { if (node_id == 0) { return; } + auto apply_to_entry = [&](NodeEntry& entry) + { + if (update.short_name && update.short_name[0] != '\0') + { + strncpy(entry.short_name, update.short_name, sizeof(entry.short_name) - 1); + entry.short_name[sizeof(entry.short_name) - 1] = '\0'; + } + if (update.long_name && update.long_name[0] != '\0') + { + strncpy(entry.long_name, update.long_name, sizeof(entry.long_name) - 1); + entry.long_name[sizeof(entry.long_name) - 1] = '\0'; + } + if (update.has_last_seen) + { + entry.last_seen = update.last_seen; + } + if (update.has_snr) + { + entry.snr = update.snr; + } + if (update.has_rssi) + { + entry.rssi = update.rssi; + } + if (update.has_hops_away) + { + entry.hops_away = update.hops_away; + } + if (update.has_channel) + { + entry.channel = update.channel; + } + if (update.has_next_hop) + { + entry.next_hop = update.next_hop; + } + if (update.has_protocol) + { + entry.protocol = update.protocol; + } + if (update.has_role) + { + entry.role = update.role; + } + if (update.has_hw_model) + { + entry.hw_model = update.hw_model; + } + if (update.has_macaddr) + { + entry.has_macaddr = true; + memcpy(entry.macaddr, update.macaddr, sizeof(entry.macaddr)); + } + if (update.has_via_mqtt) + { + entry.via_mqtt = update.via_mqtt; + } + if (update.has_is_ignored) + { + entry.is_ignored = update.is_ignored; + } + if (update.has_public_key) + { + entry.has_public_key = update.public_key_present; + } + if (update.has_key_manually_verified) + { + entry.key_manually_verified = update.key_manually_verified; + } + if (update.has_device_metrics) + { + entry.has_device_metrics = true; + entry.device_metrics = update.device_metrics; + } + if (update.has_position) + { + entry.position_valid = update.position.valid; + entry.position_latitude_i = update.position.latitude_i; + entry.position_longitude_i = update.position.longitude_i; + entry.position_has_altitude = update.position.has_altitude; + entry.position_altitude = update.position.altitude; + entry.position_timestamp = update.position.timestamp; + entry.position_precision_bits = update.position.precision_bits; + entry.position_pdop = update.position.pdop; + entry.position_hdop = update.position.hdop; + entry.position_vdop = update.position.vdop; + entry.position_gps_accuracy_mm = update.position.gps_accuracy_mm; + } + }; + for (auto& entry : entries_) { if (entry.node_id != node_id) { continue; } - - entry.position_valid = position.valid; - entry.position_latitude_i = position.latitude_i; - entry.position_longitude_i = position.longitude_i; - entry.position_has_altitude = position.has_altitude; - entry.position_altitude = position.altitude; - entry.position_timestamp = position.timestamp; - entry.position_precision_bits = position.precision_bits; - entry.position_pdop = position.pdop; - entry.position_hdop = position.hdop; - entry.position_vdop = position.vdop; - entry.position_gps_accuracy_mm = position.gps_accuracy_mm; + apply_to_entry(entry); dirty_ = true; maybeSave(); return; @@ -368,26 +431,61 @@ void NodeStoreCore::updatePosition(uint32_t node_id, const NodePosition& positio entry.rssi = std::numeric_limits::quiet_NaN(); entry.hops_away = 0xFF; entry.channel = 0xFF; - entry.next_hop = 0; - entry.protocol = 0; entry.role = kNodeRoleUnknown; - entry.hw_model = 0; - entry.position_valid = position.valid; - entry.position_latitude_i = position.latitude_i; - entry.position_longitude_i = position.longitude_i; - entry.position_has_altitude = position.has_altitude; - entry.position_altitude = position.altitude; - entry.position_timestamp = position.timestamp; - entry.position_precision_bits = position.precision_bits; - entry.position_pdop = position.pdop; - entry.position_hdop = position.hdop; - entry.position_vdop = position.vdop; - entry.position_gps_accuracy_mm = position.gps_accuracy_mm; + apply_to_entry(entry); entries_.push_back(entry); dirty_ = true; maybeSave(); } +void NodeStoreCore::upsert(uint32_t node_id, const char* short_name, const char* long_name, + uint32_t now_secs, float snr, float rssi, uint8_t protocol, + uint8_t role, uint8_t hops_away, uint8_t hw_model, uint8_t channel) +{ + NodeUpdate update{}; + update.short_name = short_name; + update.long_name = long_name; + update.has_last_seen = true; + update.last_seen = now_secs; + update.has_snr = !std::isnan(snr); + update.snr = snr; + update.has_rssi = !std::isnan(rssi); + update.rssi = rssi; + update.has_protocol = (protocol != 0); + update.protocol = protocol; + update.has_role = (role != kNodeRoleUnknown); + update.role = role; + update.has_hops_away = (hops_away != 0xFF); + update.hops_away = hops_away; + update.has_hw_model = (hw_model != 0); + update.hw_model = hw_model; + update.has_channel = (channel != 0xFF); + update.channel = channel; + applyUpdate(node_id, update); +} + +void NodeStoreCore::updateProtocol(uint32_t node_id, uint8_t protocol, uint32_t now_secs) +{ + if (protocol == 0) + { + return; + } + NodeUpdate update{}; + update.has_protocol = true; + update.protocol = protocol; + update.has_last_seen = true; + update.last_seen = now_secs; + applyUpdate(node_id, update); +} + +void NodeStoreCore::updatePosition(uint32_t node_id, const NodePosition& position) +{ + NodeUpdate update{}; + update.has_position = true; + update.position = position; + applyUpdate(node_id, update); +} + bool NodeStoreCore::setNextHop(uint32_t node_id, uint8_t next_hop, uint32_t now_secs) { if (node_id == 0) @@ -395,51 +493,20 @@ bool NodeStoreCore::setNextHop(uint32_t node_id, uint8_t next_hop, uint32_t now_ return false; } - for (auto& entry : entries_) + if (getNextHop(node_id) == next_hop) { - if (entry.node_id != node_id) - { - continue; - } - - if (entry.next_hop == next_hop) - { - return true; - } - - entry.next_hop = next_hop; - if (now_secs != 0) - { - entry.last_seen = now_secs; - } - dirty_ = true; - maybeSave(); return true; } - if (entries_.size() >= kMaxNodes) + NodeUpdate update{}; + update.has_next_hop = true; + update.next_hop = next_hop; + if (now_secs != 0) { - const size_t eviction_index = selectEvictionIndex(); - if (eviction_index < entries_.size()) - { - entries_.erase(entries_.begin() + static_cast(eviction_index)); - } + update.has_last_seen = true; + update.last_seen = now_secs; } - - NodeEntry entry{}; - entry.node_id = node_id; - entry.last_seen = now_secs; - entry.snr = std::numeric_limits::quiet_NaN(); - entry.rssi = std::numeric_limits::quiet_NaN(); - entry.hops_away = 0xFF; - entry.channel = 0xFF; - entry.next_hop = next_hop; - entry.protocol = 0; - entry.role = kNodeRoleUnknown; - entry.hw_model = 0; - entries_.push_back(entry); - dirty_ = true; - maybeSave(); + applyUpdate(node_id, update); return true; } @@ -482,6 +549,15 @@ void NodeStoreCore::clear() blob_store_.clearBlob(); } +bool NodeStoreCore::flush() +{ + if (!dirty_) + { + return true; + } + return saveEntries(); +} + uint32_t NodeStoreCore::computeBlobCrc(const uint8_t* data, size_t len) { uint32_t crc = 0xFFFFFFFF; @@ -545,14 +621,16 @@ bool NodeStoreCore::decodeEntries(const uint8_t* data, size_t len) return true; } + const bool is_v8_blob = (len % sizeof(PersistedNodeEntryV8)) == 0; const bool is_v7_blob = (len % kSerializedEntrySize) == 0; const bool is_v6_blob = (len % kLegacySerializedEntrySize) == 0; - if (!is_v7_blob && !is_v6_blob) + if (!is_v8_blob && !is_v7_blob && !is_v6_blob) { return false; } - const size_t entry_size = is_v7_blob ? kSerializedEntrySize : kLegacySerializedEntrySize; + const size_t entry_size = is_v8_blob ? sizeof(PersistedNodeEntryV8) + : (is_v7_blob ? kSerializedEntrySize : kLegacySerializedEntrySize); size_t count = len / entry_size; if (count > kMaxNodes) { @@ -561,7 +639,17 @@ bool NodeStoreCore::decodeEntries(const uint8_t* data, size_t len) entries_.clear(); entries_.reserve(count); - if (is_v7_blob) + if (is_v8_blob) + { + auto* persisted = reinterpret_cast(data); + for (size_t index = 0; index < count; ++index) + { + NodeEntry entry{}; + copyFromPersisted(entry, persisted[index]); + entries_.push_back(entry); + } + } + else if (is_v7_blob) { auto* persisted = reinterpret_cast(data); for (size_t index = 0; index < count; ++index) @@ -592,19 +680,19 @@ void NodeStoreCore::encodeEntries(std::vector& out) const return; } - std::vector persisted(entries_.size()); + std::vector persisted(entries_.size()); for (size_t index = 0; index < entries_.size(); ++index) { copyIntoPersisted(persisted[index], entries_[index]); } - out.resize(persisted.size() * sizeof(PersistedNodeEntryV7)); + out.resize(persisted.size() * sizeof(PersistedNodeEntryV8)); memcpy(out.data(), persisted.data(), out.size()); } void NodeStoreCore::maybeSave() { - if (!dirty_) + if (!dirty_ || !auto_save_enabled_) { return; } diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 75d40485..3819af73 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -50,21 +50,19 @@ MessageId ChatService::sendTextWithId(ChannelId channel, const std::string& text return 0; } - // Try to send via adapter first to get packet ID MessageId msg_id = 0; bool queued = adapter_.sendTextWithId(channel, text, forced_msg_id, &msg_id, peer); ChatMessage msg; msg.protocol = active_protocol_; msg.channel = channel; - msg.from = 0; // Local message + msg.from = 0; msg.peer = normalize_conversation_peer(peer); msg.msg_id = msg_id; msg.timestamp = now_message_timestamp(); msg.text = text; msg.status = queued ? MessageStatus::Queued : MessageStatus::Failed; - // Queue in model (if enabled) if (model_enabled_) { model_.onSendQueued(msg); @@ -74,9 +72,29 @@ MessageId ChatService::sendTextWithId(ChannelId channel, const std::string& text } } - // Store message store_.append(msg); + if (queued && msg_id != 0) + { + MeshIncomingText outgoing{}; + outgoing.channel = channel; + outgoing.from = adapter_.getNodeId(); + outgoing.to = (peer != 0) ? peer : 0xFFFFFFFFUL; + outgoing.msg_id = msg_id; + outgoing.timestamp = msg.timestamp; + outgoing.text = text; + outgoing.hop_limit = 0; + outgoing.encrypted = false; + + for (auto* observer : outgoing_text_observers_) + { + if (observer) + { + observer->onOutgoingText(outgoing); + } + } + } + return msg.msg_id; } @@ -88,7 +106,6 @@ bool ChatService::triggerDiscoveryAction(MeshDiscoveryAction action) void ChatService::switchChannel(ChannelId channel) { current_channel_ = channel; - // Could emit event here } bool ChatService::resendFailed(MessageId msg_id) @@ -99,11 +116,9 @@ bool ChatService::resendFailed(MessageId msg_id) return false; } - // Resend via adapter MessageId new_msg_id = 0; if (adapter_.sendText(msg->channel, msg->text, &new_msg_id, msg->peer)) { - // Create new queued message ChatMessage resend_msg = *msg; resend_msg.msg_id = (new_msg_id != 0) ? new_msg_id : msg_id; resend_msg.status = MessageStatus::Queued; @@ -152,43 +167,39 @@ void ChatService::markConversationRead(const ConversationId& conv) void ChatService::processIncoming() { - MeshIncomingText incoming; - while (adapter_.pollIncomingText(&incoming)) + MeshIncomingText incoming_text; + while (adapter_.pollIncomingText(&incoming_text)) { - // Convert to ChatMessage ChatMessage msg; msg.protocol = active_protocol_; - msg.channel = incoming.channel; - msg.from = incoming.from; - msg.peer = normalize_conversation_peer(incoming.to) == 0 ? 0 : incoming.from; - msg.msg_id = incoming.msg_id; - // Use local receive time to avoid sender clock skew. + msg.channel = incoming_text.channel; + msg.from = incoming_text.from; + msg.peer = normalize_conversation_peer(incoming_text.to) == 0 ? 0 : incoming_text.from; + msg.msg_id = incoming_text.msg_id; msg.timestamp = now_message_timestamp(); - msg.text = incoming.text; + msg.text = incoming_text.text; msg.status = MessageStatus::Incoming; - CHAT_SERVICE_LOG("[ChatService] incoming ch=%u from=%08lX to=%08lX peer=%08lX ts=%lu len=%u\n", + CHAT_SERVICE_LOG("[ChatService] incoming text ch=%u from=%08lX to=%08lX peer=%08lX ts=%lu len=%u\n", static_cast(msg.channel), static_cast(msg.from), - static_cast(incoming.to), + static_cast(incoming_text.to), static_cast(msg.peer), static_cast(msg.timestamp), static_cast(msg.text.size())); - // Add to model (if enabled) if (model_enabled_) { model_.onIncoming(msg); } - // Store store_.append(msg); for (auto* observer : incoming_message_observers_) { if (observer) { - observer->onIncomingMessage(msg, &incoming.rx_meta); + observer->onIncomingMessage(msg, &incoming_text.rx_meta); } } @@ -196,10 +207,35 @@ void ChatService::processIncoming() { if (observer) { - observer->onIncomingText(incoming); + observer->onIncomingText(incoming_text); } } } + + MeshIncomingData incoming_data; + while (adapter_.pollIncomingData(&incoming_data)) + { + CHAT_SERVICE_LOG("[ChatService] incoming data ch=%u from=%08lX to=%08lX pkt=%08lX port=%u len=%u\n", + static_cast(incoming_data.channel), + static_cast(incoming_data.from), + static_cast(incoming_data.to), + static_cast(incoming_data.packet_id), + static_cast(incoming_data.portnum), + static_cast(incoming_data.payload.size())); + + for (auto* observer : incoming_data_observers_) + { + if (observer) + { + observer->onIncomingData(incoming_data); + } + } + } +} + +void ChatService::flushStore() +{ + store_.flush(); } void ChatService::addIncomingMessageObserver(IncomingMessageObserver* observer) @@ -234,9 +270,76 @@ void ChatService::removeIncomingMessageObserver(IncomingMessageObserver* observe } } +void ChatService::addOutgoingTextObserver(OutgoingTextObserver* observer) +{ + if (!observer) + { + return; + } + for (auto* existing : outgoing_text_observers_) + { + if (existing == observer) + { + return; + } + } + outgoing_text_observers_.push_back(observer); +} + +void ChatService::removeOutgoingTextObserver(OutgoingTextObserver* observer) +{ + if (!observer) + { + return; + } + for (auto it = outgoing_text_observers_.begin(); it != outgoing_text_observers_.end(); ++it) + { + if (*it == observer) + { + outgoing_text_observers_.erase(it); + return; + } + } +} + +void ChatService::addIncomingDataObserver(IncomingDataObserver* observer) +{ + if (!observer) + { + return; + } + for (auto* existing : incoming_data_observers_) + { + if (existing == observer) + { + return; + } + } + incoming_data_observers_.push_back(observer); +} + +void ChatService::removeIncomingDataObserver(IncomingDataObserver* observer) +{ + if (!observer) + { + return; + } + for (auto it = incoming_data_observers_.begin(); it != incoming_data_observers_.end(); ++it) + { + if (*it == observer) + { + incoming_data_observers_.erase(it); + return; + } + } +} + void ChatService::handleSendResult(MessageId msg_id, bool ok) { - if (msg_id == 0) return; + if (msg_id == 0) + { + return; + } if (model_enabled_) { model_.onSendResult(msg_id, ok); diff --git a/modules/core_chat/src/usecase/contact_service.cpp b/modules/core_chat/src/usecase/contact_service.cpp index 8bb25f43..0b992dd5 100644 --- a/modules/core_chat/src/usecase/contact_service.cpp +++ b/modules/core_chat/src/usecase/contact_service.cpp @@ -46,6 +46,12 @@ void ContactService::begin() invalidateCache(); } +void ContactService::applyNodeUpdate(uint32_t node_id, const NodeUpdate& update) +{ + node_store_.applyUpdate(node_id, update); + invalidateCache(); +} + void ContactService::updateNodeInfo(uint32_t node_id, const char* short_name, const char* long_name, float snr, float rssi, uint32_t now_secs, uint8_t protocol, uint8_t role, uint8_t hops_away, uint8_t hw_model, uint8_t channel) @@ -55,21 +61,44 @@ void ContactService::updateNodeInfo(uint32_t node_id, const char* short_name, co snr, rssi, (unsigned long)now_secs); - node_store_.upsert(node_id, short_name, long_name, now_secs, snr, rssi, - protocol, role, hops_away, hw_model, channel); - invalidateCache(); + NodeUpdate update{}; + update.short_name = short_name; + update.long_name = long_name; + update.has_last_seen = true; + update.last_seen = now_secs; + update.has_snr = !std::isnan(snr); + update.snr = snr; + update.has_rssi = !std::isnan(rssi); + update.rssi = rssi; + update.has_protocol = (protocol != 0); + update.protocol = protocol; + update.has_role = (role != kNodeRoleUnknown); + update.role = role; + update.has_hops_away = (hops_away != 0xFF); + update.hops_away = hops_away; + update.has_hw_model = (hw_model != 0); + update.hw_model = hw_model; + update.has_channel = (channel != 0xFF); + update.channel = channel; + applyNodeUpdate(node_id, update); } void ContactService::updateNodeProtocol(uint32_t node_id, uint8_t protocol, uint32_t now_secs) { - node_store_.updateProtocol(node_id, protocol, now_secs); - invalidateCache(); + NodeUpdate update{}; + update.has_protocol = (protocol != 0); + update.protocol = protocol; + update.has_last_seen = true; + update.last_seen = now_secs; + applyNodeUpdate(node_id, update); } void ContactService::updateNodePosition(uint32_t node_id, const NodePosition& pos) { - node_store_.updatePosition(node_id, pos); - invalidateCache(); + NodeUpdate update{}; + update.has_position = true; + update.position = pos; + applyNodeUpdate(node_id, update); } std::string ContactService::getContactName(uint32_t node_id) const @@ -121,7 +150,7 @@ std::vector ContactService::getNearby() const std::vector nearby; for (const auto& node : cached_nodes_) { - if (!node.is_contact && isNodeVisible(node.last_seen)) + if (!node.is_contact && !node.is_ignored && isNodeVisible(node.last_seen)) { nearby.push_back(node); } @@ -129,6 +158,20 @@ std::vector ContactService::getNearby() const return nearby; } +std::vector ContactService::getIgnoredNodes() const +{ + buildCache(); + std::vector ignored; + for (const auto& node : cached_nodes_) + { + if (!node.is_contact && node.is_ignored && isNodeVisible(node.last_seen)) + { + ignored.push_back(node); + } + } + return ignored; +} + bool ContactService::addContact(uint32_t node_id, const char* nickname) { if (!ensureNodeExistsForContact(node_id)) @@ -185,6 +228,34 @@ bool ContactService::removeNode(uint32_t node_id) return removed; } +bool ContactService::setNodeIgnored(uint32_t node_id, bool ignored) +{ + if (!hasNodeEntry(node_id)) + { + return false; + } + + NodeUpdate update{}; + update.has_is_ignored = true; + update.is_ignored = ignored; + applyNodeUpdate(node_id, update); + return true; +} + +bool ContactService::setNodeKeyManuallyVerified(uint32_t node_id, bool verified) +{ + if (!hasNodeEntry(node_id)) + { + return false; + } + + NodeUpdate update{}; + update.has_key_manually_verified = true; + update.key_manually_verified = verified; + applyNodeUpdate(node_id, update); + return true; +} + const NodeInfo* ContactService::getNodeInfo(uint32_t node_id) const { buildCache(); @@ -242,6 +313,16 @@ void ContactService::buildCache() const info.channel = entry.channel; info.protocol = static_cast(entry.protocol); info.role = static_cast(entry.role); + info.hw_model = entry.hw_model; + info.next_hop = entry.next_hop; + info.has_macaddr = entry.has_macaddr; + std::memcpy(info.macaddr, entry.macaddr, sizeof(info.macaddr)); + info.via_mqtt = entry.via_mqtt; + info.is_ignored = entry.is_ignored; + info.has_public_key = entry.has_public_key; + info.key_manually_verified = entry.key_manually_verified; + info.has_device_metrics = entry.has_device_metrics; + info.device_metrics = entry.device_metrics; info.position.valid = entry.position_valid; info.position.latitude_i = entry.position_latitude_i; info.position.longitude_i = entry.position_longitude_i; @@ -293,17 +374,10 @@ bool ContactService::ensureNodeExistsForContact(uint32_t node_id) return true; } - node_store_.upsert(node_id, - nullptr, - nullptr, - 0, - std::numeric_limits::quiet_NaN(), - std::numeric_limits::quiet_NaN(), - 0, - kNodeRoleUnknown, - 0xFF, - 0, - 0xFF); + NodeUpdate update{}; + update.has_last_seen = true; + update.last_seen = 0; + applyNodeUpdate(node_id, update); return hasNodeEntry(node_id); } diff --git a/modules/core_sys/include/app/app_config.h b/modules/core_sys/include/app/app_config.h index b1f7380d..28d3353d 100644 --- a/modules/core_sys/include/app/app_config.h +++ b/modules/core_sys/include/app/app_config.h @@ -63,12 +63,15 @@ struct AppConfig static constexpr uint8_t kMeshCoreDefaultCr = 5; static constexpr int8_t kMeshCoreDefaultTxPowerDbm = 20; static constexpr int8_t kTxPowerMinDbm = -9; -#if defined(ARDUINO_LILYGO_LORA_SX1262) +#if defined(TRAIL_MATE_LORA_TX_POWER_MAX_DBM) + // Board/module capability must be declared per build target. + // Examples: SX1262 22S => 22 dBm, 30S => 30 dBm, 33S => 33 dBm. + static constexpr int8_t kTxPowerMaxDbm = TRAIL_MATE_LORA_TX_POWER_MAX_DBM; +#elif defined(__INTELLISENSE__) + // Keep IDE parsing usable when build flags are not loaded. static constexpr int8_t kTxPowerMaxDbm = 22; -#elif defined(ARDUINO_LILYGO_LORA_SX1280) - static constexpr int8_t kTxPowerMaxDbm = 13; #else - static constexpr int8_t kTxPowerMaxDbm = 20; +#error "Define TRAIL_MATE_LORA_TX_POWER_MAX_DBM for this build target." #endif // Chat settings chat::ChatPolicy chat_policy; diff --git a/modules/core_sys/include/platform/ui/device_runtime.h b/modules/core_sys/include/platform/ui/device_runtime.h index 4435d42d..27fa058e 100644 --- a/modules/core_sys/include/platform/ui/device_runtime.h +++ b/modules/core_sys/include/platform/ui/device_runtime.h @@ -17,6 +17,10 @@ void restart(); bool rtc_ready(); BatteryInfo battery_info(); void handle_low_battery(const BatteryInfo& info); +bool supports_screen_brightness(); +uint8_t screen_brightness(); +void set_screen_brightness(uint8_t level); +void trigger_haptic(); uint8_t default_message_tone_volume(); void set_message_tone_volume(uint8_t volume_percent); void play_message_tone(); diff --git a/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h b/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h index 5ae85805..f0276e10 100644 --- a/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h +++ b/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h @@ -199,6 +199,8 @@ class Runtime : public chat::ChatService::IncomingTextObserver void saveEditedTextToConfig(); void formatTime(char* out_time, size_t out_len, char* out_date, size_t date_len) const; void formatTimestamp(char* out, size_t out_len, uint32_t timestamp_s) const; + void formatConversationTime(char* out, size_t out_len, uint32_t timestamp_s, bool expanded = false) const; + void formatConversationSender(char* out, size_t out_len, const chat::ChatMessage& msg, bool expanded = false) const; void formatProtocol(char* out, size_t out_len) const; void formatNodeLabel(char* out, size_t out_len) const; void formatComposeTarget(char* out, size_t out_len) const; @@ -207,10 +209,14 @@ class Runtime : public chat::ChatService::IncomingTextObserver void drawFooterHint(const char* hint); void drawTextClipped(int x, int y, int w, const char* text, bool inverse = false); void drawConversationText(int x, int y, int w, const char* text, bool selected, bool align_right); + void drawConversationBubble(int x, int y, int w, const char* sender, const char* time_text, + const char* text, bool selected, bool align_right); void refreshGnssSnapshot(bool force = false); bool editUsesHexCharset() const; bool usesSmartCompose() const; void executeActionPageItem(size_t index); + size_t nodeActionCount() const; + const char* nodeActionLabel(size_t index) const; uint32_t nowMs() const; app::IAppFacade* app() const; @@ -246,6 +252,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver size_t setting_popup_index_ = 0; app::AppConfig setting_popup_config_{}; bool setting_popup_ble_enabled_ = false; + uint32_t setting_popup_screen_timeout_ms_ = 30000; int setting_popup_timezone_min_ = 0; size_t info_scroll_ = 0; size_t action_index_ = 0; diff --git a/modules/ui_mono_128x64/src/runtime.cpp b/modules/ui_mono_128x64/src/runtime.cpp index efc24a64..c2690839 100644 --- a/modules/ui_mono_128x64/src/runtime.cpp +++ b/modules/ui_mono_128x64/src/runtime.cpp @@ -11,6 +11,7 @@ #include "generated/meshtastic/mesh.pb.h" #include "generated/meshtastic/portnums.pb.h" #include "pb_encode.h" +#include "platform/ui/screen_runtime.h" #include #include #include @@ -70,7 +71,7 @@ constexpr const char* kMeshtasticRadioItems[] = { "REGION", "TX POWER", "MODEM", - "CHANNEL", + "CH SLOT", "ENCRYPT", }; @@ -84,11 +85,43 @@ constexpr const char* kMeshCoreRadioItems[] = { "NAME", }; +constexpr uint16_t kMeshtasticChannelNumMax = 16; +constexpr uint32_t kScreenTimeoutAlwaysMs = 300000UL; +constexpr uint32_t kScreenTimeoutOptionsMs[] = {15000UL, 30000UL, 60000UL, kScreenTimeoutAlwaysMs}; + +uint16_t normalizedMeshtasticChannelNum(uint16_t channel_num) +{ + return std::min(channel_num, kMeshtasticChannelNumMax); +} + +void sanitizeMeshtasticChannelNum(app::AppConfig& cfg) +{ + cfg.meshtastic_config.channel_num = normalizedMeshtasticChannelNum(cfg.meshtastic_config.channel_num); +} + +void formatMeshtasticChannelSlot(char* out, size_t out_len, uint16_t channel_num) +{ + if (!out || out_len == 0) + { + return; + } + + const unsigned slot = static_cast(normalizedMeshtasticChannelNum(channel_num)); + if (slot == 0U) + { + std::snprintf(out, out_len, "Auto"); + return; + } + + std::snprintf(out, out_len, "%u", slot); +} + constexpr const char* kDeviceItems[] = { "BLE", "GPS", "SATS", "GPS INT", + "SCREEN OFF", "TIME ZONE", }; @@ -104,14 +137,7 @@ constexpr const char* kMessageMenuItems[] = { "REPLY", }; -constexpr const char* kNodeActionItems[] = { - "DETAIL", - "ADD CONTACT", - "TRACE ROUTE", - "KEY VERIFICATION", - "EXCHANGE POSITION", - "OPEN COMPASS", -}; +constexpr size_t kNodeActionItemCount = 7; constexpr const char* kWeekdays[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; constexpr const char* kComposeCharset = @@ -143,7 +169,6 @@ constexpr ComposeGroupDef kComposeAbcGroups[] = { {".,?", ".,?"}, }; constexpr uint32_t kBootMinMs = 1800; -constexpr uint32_t kSleepTimeoutMs = 30000; constexpr uint32_t kComposeMultiTapWindowMs = 700; constexpr size_t kChatListPageSize = 6; constexpr size_t kNodeListPageSize = 6; @@ -262,6 +287,52 @@ constexpr T clampValue(T value, T low, T high) return value < low ? low : (value > high ? high : value); } +uint32_t normalizedScreenTimeoutMs(uint32_t timeout_ms) +{ + return platform::ui::screen::clamp_timeout_ms(timeout_ms); +} + +size_t screenTimeoutOptionIndex(uint32_t timeout_ms) +{ + timeout_ms = normalizedScreenTimeoutMs(timeout_ms); + size_t index = 0; + while (index + 1 < arrayCount(kScreenTimeoutOptionsMs) && + kScreenTimeoutOptionsMs[index] < timeout_ms) + { + ++index; + } + return index; +} + +uint32_t stepScreenTimeoutMs(uint32_t current_timeout_ms, int delta) +{ + const int current = static_cast(screenTimeoutOptionIndex(current_timeout_ms)); + const int next = clampValue(current + delta, 0, static_cast(arrayCount(kScreenTimeoutOptionsMs)) - 1); + return kScreenTimeoutOptionsMs[next]; +} + +void formatScreenTimeoutLabel(char* out, size_t out_len, uint32_t timeout_ms) +{ + if (!out || out_len == 0) + { + return; + } + + const uint32_t normalized = normalizedScreenTimeoutMs(timeout_ms); + if (normalized >= kScreenTimeoutAlwaysMs) + { + std::snprintf(out, out_len, "Always"); + return; + } + if (normalized % 60000UL == 0) + { + std::snprintf(out, out_len, "%lumin", static_cast(normalized / 60000UL)); + return; + } + + std::snprintf(out, out_len, "%lus", static_cast(normalized / 1000UL)); +} + size_t utf8CharLength(unsigned char lead) { if ((lead & 0x80U) == 0) @@ -1407,7 +1478,7 @@ void Runtime::handleInput(InputAction action) { --node_action_index_; } - else if (action == InputAction::Down && node_action_index_ + 1 < arrayCount(kNodeActionItems)) + else if (action == InputAction::Down && node_action_index_ + 1 < nodeActionCount()) { ++node_action_index_; } @@ -2323,11 +2394,43 @@ void Runtime::renderNodeCompass() const double abs_bearing_deg = bearingDegrees(gps.lat, gps.lng, node_lat, node_lon); const double rel_bearing_deg = gps.has_course ? normalizeBearingDeg(abs_bearing_deg - gps.course_deg) : abs_bearing_deg; - constexpr int kCenterX = 20; + constexpr int kCenterX = 25; constexpr int kCenterY = 39; constexpr int kRadius = 17; - constexpr int kInfoX = 42; - constexpr int kInfoW = 86; + constexpr int kInfoRightX = 127; + constexpr int kInfoW = 58; + constexpr int kInfoMinX = kInfoRightX - kInfoW + 1; + auto drawInfoRight = [this](int y, const char* text) + { + if (!text || text[0] == '\0') + { + return; + } + + const char* draw_text = text; + char clipped[32] = {}; + if (text_renderer_.measureTextWidth(text) > kInfoW) + { + if (kInfoW > text_renderer_.ellipsisWidth()) + { + const size_t keep_bytes = text_renderer_.clipTextToWidth(text, kInfoW - text_renderer_.ellipsisWidth()); + std::memcpy(clipped, text, keep_bytes); + clipped[keep_bytes] = '\0'; + std::strcat(clipped, "..."); + } + else + { + const size_t keep_bytes = text_renderer_.clipTextToWidth(text, kInfoW); + std::memcpy(clipped, text, keep_bytes); + clipped[keep_bytes] = '\0'; + } + draw_text = clipped; + } + + const int draw_w = text_renderer_.measureTextWidth(draw_text); + const int draw_x = std::max(kInfoMinX, kInfoRightX - draw_w); + text_renderer_.drawText(display_, draw_x, y, draw_text); + }; drawCircle(display_, kCenterX, kCenterY, kRadius); display_.drawPixel(kCenterX, kCenterY, true); text_renderer_.drawText(display_, kCenterX - 2, kCenterY - kRadius - 7, "N"); @@ -2356,22 +2459,22 @@ void Runtime::renderNodeCompass() { std::snprintf(line, sizeof(line), "DST %.2fkm", dist_m / 1000.0); } - drawTextClipped(kInfoX, 18, kInfoW, line); + drawInfoRight(18, line); std::snprintf(line, sizeof(line), "BRG %s %.0f", bearingCardinal(abs_bearing_deg), abs_bearing_deg); - drawTextClipped(kInfoX, 26, kInfoW, line); + drawInfoRight(26, line); if (gps.has_course) { std::snprintf(line, sizeof(line), "HDG %.0f", gps.course_deg); - drawTextClipped(kInfoX, 34, kInfoW, line); + drawInfoRight(34, line); std::snprintf(line, sizeof(line), "REL %.0f", rel_bearing_deg); - drawTextClipped(kInfoX, 42, kInfoW, line); + drawInfoRight(42, line); } else { - drawTextClipped(kInfoX, 34, kInfoW, "HDG N/A"); - drawTextClipped(kInfoX, 42, kInfoW, "REL N/A"); + drawInfoRight(34, "HDG N/A"); + drawInfoRight(42, "REL N/A"); } if (node->position.has_altitude) @@ -2382,7 +2485,7 @@ void Runtime::renderNodeCompass() { std::snprintf(line, sizeof(line), "ALT -"); } - drawTextClipped(kInfoX, 50, kInfoW, line); + drawInfoRight(50, line); char age_buf[12] = {}; formatElapsedShort(host_.utc_now_fn ? host_.utc_now_fn() : 0, node->last_seen, age_buf, sizeof(age_buf)); @@ -2401,18 +2504,23 @@ void Runtime::renderNodeCompass() static_cast(node->hops_away), age_buf); } - drawTextClipped(kInfoX, 58, kInfoW, footer); + drawInfoRight(58, footer); } void Runtime::renderNodeActionMenu() { const auto* node = selectedNode(); + std::array items{}; + for (size_t i = 0; i < items.size(); ++i) + { + items[i] = nodeActionLabel(i); + } char title[20] = {}; if (node) { std::snprintf(title, sizeof(title), "%04lX", static_cast(node->node_id & 0xFFFFUL)); } - drawMenuList(title[0] != '\0' ? title : "NODE", kNodeActionItems, arrayCount(kNodeActionItems), node_action_index_); + drawMenuList(title[0] != '\0' ? title : "NODE", items.data(), items.size(), node_action_index_); } void Runtime::renderConversation() @@ -2436,9 +2544,16 @@ void Runtime::renderConversation() } const int line_h = text_renderer_.lineHeight(); + constexpr int kConversationStartY = 10; + constexpr int kBubbleGap = 1; + constexpr int kBubbleWidth = 118; + constexpr int kBubbleBodyBottomPadding = 2; + constexpr size_t kVisibleConversationBubbles = 2; + const int bubble_h = (line_h * 2) + 1 + kBubbleBodyBottomPadding; + const int row_h = bubble_h + kBubbleGap; + const int bubble_w = std::max(8, std::min(kBubbleWidth, display_.width() - 2)); + const size_t visible = std::min(message_count_, kVisibleConversationBubbles); const size_t selected_index = std::min(message_index_, message_count_ - 1U); - const size_t visible = std::min(message_count_, static_cast(6)); - constexpr int kMessageViewportWidth = 90; size_t start = 0; if (message_count_ > visible) { @@ -2453,19 +2568,36 @@ void Runtime::renderConversation() const size_t msg_index = start + i; const auto& msg = messages_[msg_index]; const bool selected = (msg_index == selected_index); - std::string line; - if (active_conversation_.peer == 0 && msg.from != 0) + char sender[16] = {}; + char time_text[16] = {}; + uint32_t display_timestamp = msg.timestamp; + if (selected && display_timestamp < 1700000000U) { - char sender[12] = {}; - std::snprintf(sender, sizeof(sender), "%04lX ", static_cast(msg.from & 0xFFFFUL)); - line = sender; + for (size_t meta_index = 0; meta_index < kMessageMetaCapacity; ++meta_index) + { + const MessageMetaEntry& candidate = message_meta_[meta_index]; + if (!candidate.used) + { + continue; + } + if (candidate.protocol == msg.protocol && + candidate.channel == msg.channel && + candidate.from == msg.from && + candidate.msg_id == msg.msg_id && + candidate.rx_meta.rx_timestamp_s >= 1700000000U) + { + display_timestamp = candidate.rx_meta.rx_timestamp_s; + break; + } + } } - line += msg.text; + formatConversationSender(sender, sizeof(sender), msg, selected); + formatConversationTime(time_text, sizeof(time_text), display_timestamp, selected); - const int y = 10 + static_cast(i * line_h); + const int y = kConversationStartY + static_cast(i * row_h); const bool is_self = (msg.from == 0); - const int x = is_self ? (display_.width() - kMessageViewportWidth) : 0; - drawConversationText(x, y, kMessageViewportWidth, line.c_str(), selected, is_self); + const int x = is_self ? (display_.width() - bubble_w - 1) : 1; + drawConversationBubble(x, y, bubble_w, sender, time_text, msg.text.c_str(), selected, is_self); } } @@ -2715,7 +2847,7 @@ void Runtime::renderRadioSettings() case 4: if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) { - std::snprintf(value, sizeof(value), "Slot %u", static_cast(cfg.meshtastic_config.channel_num)); + formatMeshtasticChannelSlot(value, sizeof(value), cfg.meshtastic_config.channel_num); } else { @@ -2781,6 +2913,12 @@ void Runtime::renderDeviceSettings() static_cast(app()->getConfig().gps_interval_ms / 1000UL)); } else if (i == 4) + { + char timeout_label[16] = {}; + formatScreenTimeoutLabel(timeout_label, sizeof(timeout_label), platform::ui::screen::timeout_ms()); + std::snprintf(line, sizeof(line), "SCREEN OFF: %s", timeout_label); + } + else if (i == 5) { const int tz = host_.timezone_offset_min_fn ? host_.timezone_offset_min_fn() : 0; char tz_label[16] = {}; @@ -2878,7 +3016,7 @@ void Runtime::renderTransientPopup() void Runtime::renderInfoPage() { - char lines[24][40] = {}; + char lines[32][40] = {}; size_t line_count = 0; auto push_line = [&](const char* text) { @@ -2948,8 +3086,8 @@ void Runtime::renderInfoPage() { push_kv("MODEM", chat::meshtastic::presetDisplayName( static_cast(cfg.meshtastic_config.modem_preset))); - std::snprintf(value, sizeof(value), "Slot %u", static_cast(cfg.meshtastic_config.channel_num)); - push_kv("CHAN", value); + formatMeshtasticChannelSlot(value, sizeof(value), cfg.meshtastic_config.channel_num); + push_kv("CH SLOT", value); push_kv("ENCRYPT", encryptEnabled(cfg) ? "ON" : "OFF"); } else @@ -3002,8 +3140,10 @@ void Runtime::renderInfoPage() } if (flash.available && flash.total_bytes > 0) { - std::snprintf(value, sizeof(value), "%lu/%luK", - static_cast(flash.used_bytes / 1024U), + const uint32_t flash_free_bytes = + flash.total_bytes > flash.used_bytes ? (flash.total_bytes - flash.used_bytes) : 0U; + std::snprintf(value, sizeof(value), "%lu/%luK free", + static_cast(flash_free_bytes / 1024U), static_cast(flash.total_bytes / 1024U)); push_kv("FLASH", value); } @@ -3272,7 +3412,7 @@ void Runtime::enterPage(Page page) } else if (page == Page::NodeCompass) { - node_action_index_ = std::min(node_action_index_, arrayCount(kNodeActionItems) - 1U); + node_action_index_ = std::min(node_action_index_, nodeActionCount() - 1U); } else if (page == Page::Conversation) { @@ -3375,7 +3515,9 @@ void Runtime::rebuildNodeList() auto contacts = app()->getContactService().getContacts(); auto nearby = app()->getContactService().getNearby(); + auto ignored = app()->getContactService().getIgnoredNodes(); contacts.insert(contacts.end(), nearby.begin(), nearby.end()); + contacts.insert(contacts.end(), ignored.begin(), ignored.end()); std::sort(contacts.begin(), contacts.end(), [](const chat::contacts::NodeInfo& a, const chat::contacts::NodeInfo& b) { @@ -3467,6 +3609,59 @@ void Runtime::buildNodeInfo() formatElapsedShort(host_.utc_now_fn ? host_.utc_now_fn() : 0, node.last_seen, value, sizeof(value)); push_kv("AGO", value); push_kv("SIG", signalRatingLabel(node.snr, node.rssi)); + std::snprintf(value, sizeof(value), "%u", static_cast(node.hw_model)); + push_kv("HW", node.hw_model == 0 ? "-" : value); + std::snprintf(value, sizeof(value), "%02X", static_cast(node.next_hop)); + push_kv("NH", node.next_hop == 0 ? "-" : value); + push_kv("MQTT", node.via_mqtt ? "Y" : "N"); + push_kv("IGN", node.is_ignored ? "Y" : "N"); + if (node.has_macaddr) + { + char mac_buf[24]; + std::snprintf(mac_buf, + sizeof(mac_buf), + "%02X:%02X:%02X:%02X:%02X:%02X", + static_cast(node.macaddr[0]), + static_cast(node.macaddr[1]), + static_cast(node.macaddr[2]), + static_cast(node.macaddr[3]), + static_cast(node.macaddr[4]), + static_cast(node.macaddr[5])); + push_kv("MAC", mac_buf); + } + push_kv("PKI", node.key_manually_verified ? "VERIFIED" : (node.has_public_key ? "KNOWN" : "-")); + if (node.has_device_metrics) + { + if (node.device_metrics.has_battery_level) + { + std::snprintf(value, sizeof(value), "%lu%%", + static_cast(node.device_metrics.battery_level)); + push_kv("BAT", value); + } + if (node.device_metrics.has_voltage) + { + std::snprintf(value, sizeof(value), "%.2fV", static_cast(node.device_metrics.voltage)); + push_kv("V", value); + } + if (node.device_metrics.has_channel_utilization) + { + std::snprintf(value, sizeof(value), "%.1f%%", + static_cast(node.device_metrics.channel_utilization)); + push_kv("UTIL", value); + } + if (node.device_metrics.has_air_util_tx) + { + std::snprintf(value, sizeof(value), "%.1f%%", + static_cast(node.device_metrics.air_util_tx)); + push_kv("AIR", value); + } + if (node.device_metrics.has_uptime_seconds) + { + std::snprintf(value, sizeof(value), "%lu", + static_cast(node.device_metrics.uptime_seconds)); + push_kv("UP", value); + } + } push_section("POS"); if (node.position.valid) @@ -3583,6 +3778,91 @@ void Runtime::buildMessageInfo() ++message_info_count_; }; + auto fitUtf8Bytes = [](const char* text, size_t max_bytes) + { + if (!text || max_bytes == 0) + { + return static_cast(0); + } + + size_t used = 0; + while (text[used] != '\0') + { + const size_t char_len = utf8CharLength(static_cast(text[used])); + if (used + char_len > max_bytes) + { + break; + } + used += char_len; + } + return used; + }; + + auto push_text_block = [this, &push_line, &fitUtf8Bytes](const char* value) + { + if (!value || message_info_count_ >= kMessageInfoLines) + { + return; + } + + const int available_width = std::max(1, display_.width()); + const size_t storage_limit = std::max(1U, kMessageInfoWidth - 1U); + + const char* cursor = value; + while (message_info_count_ < kMessageInfoLines) + { + const char* logical_end = cursor; + while (*logical_end != '\0' && *logical_end != '\n' && *logical_end != '\r') + { + ++logical_end; + } + + const size_t logical_len = static_cast(logical_end - cursor); + if (logical_len == 0) + { + push_line(" "); + } + else + { + std::string logical_line(cursor, logical_len); + const char* wrapped = logical_line.c_str(); + while (*wrapped != '\0' && message_info_count_ < kMessageInfoLines) + { + size_t keep_bytes = text_renderer_.clipTextToWidth(wrapped, available_width); + if (keep_bytes == 0) + { + keep_bytes = utf8CharLength(static_cast(*wrapped)); + } + keep_bytes = std::min(keep_bytes, fitUtf8Bytes(wrapped, storage_limit)); + if (keep_bytes == 0) + { + keep_bytes = std::min(storage_limit, static_cast(1)); + } + + char chunk[kMessageInfoWidth] = {}; + std::memcpy(chunk, wrapped, std::min(keep_bytes, sizeof(chunk) - 1)); + chunk[std::min(keep_bytes, sizeof(chunk) - 1)] = '\0'; + push_line(chunk); + wrapped += keep_bytes; + } + } + + if (*logical_end == '\0') + { + break; + } + + if (*logical_end == '\r' && logical_end[1] == '\n') + { + cursor = logical_end + 2; + } + else + { + cursor = logical_end + 1; + } + } + }; + auto formatInfoTime = [this](uint32_t timestamp_s, char* out, size_t out_len) { if (!out || out_len == 0) @@ -3642,7 +3922,8 @@ void Runtime::buildMessageInfo() if (!msg->text.empty()) { - push_kv("TXT", msg->text.c_str()); + push_section("TXT"); + push_text_block(msg->text.c_str()); } const MessageMetaEntry* meta = nullptr; @@ -3769,7 +4050,18 @@ void Runtime::ensureSleepTimeout(InputAction action) return; } - if ((now - last_interaction_ms_) < kSleepTimeoutMs) + if (platform::ui::screen::is_sleep_disabled()) + { + return; + } + + const uint32_t timeout_ms = platform::ui::screen::timeout_ms(); + if (timeout_ms >= kScreenTimeoutAlwaysMs) + { + return; + } + + if ((now - last_interaction_ms_) < timeout_ms) { return; } @@ -3890,9 +4182,17 @@ void Runtime::adjustRadioSetting(int delta) case 4: if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) { + const int current = static_cast(normalizedMeshtasticChannelNum(cfg.meshtastic_config.channel_num)); cfg.meshtastic_config.channel_num = static_cast(clampValue( - static_cast(cfg.meshtastic_config.channel_num) + delta, 0, 255)); - appendStatus(this, "channel %u", static_cast(cfg.meshtastic_config.channel_num)); + current + delta, 0, static_cast(kMeshtasticChannelNumMax))); + if (cfg.meshtastic_config.channel_num == 0) + { + appendStatus(this, "channel auto"); + } + else + { + appendStatus(this, "channel slot %u", static_cast(cfg.meshtastic_config.channel_num)); + } } else { @@ -3950,7 +4250,15 @@ void Runtime::adjustDeviceSetting(int delta) commitConfig(); appendStatus(this, "gps int %lus", static_cast(app()->getConfig().gps_interval_ms / 1000UL)); } - else if (device_index_ == 4 && host_.timezone_offset_min_fn && host_.set_timezone_offset_min_fn) + else if (device_index_ == 4) + { + const uint32_t next = stepScreenTimeoutMs(platform::ui::screen::timeout_ms(), delta); + platform::ui::screen::set_timeout_ms(next); + char timeout_label[16] = {}; + formatScreenTimeoutLabel(timeout_label, sizeof(timeout_label), next); + appendStatus(this, "screen %s", timeout_label); + } + else if (device_index_ == 5 && host_.timezone_offset_min_fn && host_.set_timezone_offset_min_fn) { const int current = host_.timezone_offset_min_fn(); const int next = clampValue(current + delta * kTimezoneStep, kTimezoneMin, kTimezoneMax); @@ -3972,6 +4280,7 @@ void Runtime::beginSettingPopup(Page owner, size_t index) setting_popup_index_ = index; setting_popup_config_ = app()->getConfig(); setting_popup_ble_enabled_ = app()->isBleEnabled(); + setting_popup_screen_timeout_ms_ = platform::ui::screen::timeout_ms(); setting_popup_timezone_min_ = host_.timezone_offset_min_fn ? host_.timezone_offset_min_fn() : 0; } @@ -3996,12 +4305,14 @@ void Runtime::confirmSettingPopup() } auto& cfg = app()->getConfig(); + sanitizeMeshtasticChannelNum(setting_popup_config_); cfg = setting_popup_config_; cfg.ble_enabled = setting_popup_ble_enabled_; if (host_.set_timezone_offset_min_fn) { host_.set_timezone_offset_min_fn(setting_popup_timezone_min_); } + platform::ui::screen::set_timeout_ms(setting_popup_screen_timeout_ms_); app()->setBleEnabled(setting_popup_ble_enabled_); app()->saveConfig(); @@ -4117,8 +4428,9 @@ void Runtime::adjustSettingPopup(int delta) case 4: if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) { + const int current = static_cast(normalizedMeshtasticChannelNum(cfg.meshtastic_config.channel_num)); cfg.meshtastic_config.channel_num = static_cast(clampValue( - static_cast(cfg.meshtastic_config.channel_num) + delta, 0, 255)); + current + delta, 0, static_cast(kMeshtasticChannelNumMax))); } else { @@ -4162,6 +4474,9 @@ void Runtime::adjustSettingPopup(int delta) break; } case 4: + setting_popup_screen_timeout_ms_ = stepScreenTimeoutMs(setting_popup_screen_timeout_ms_, delta); + break; + case 5: setting_popup_timezone_min_ = clampValue(setting_popup_timezone_min_ + delta * kTimezoneStep, kTimezoneMin, kTimezoneMax); @@ -4209,7 +4524,7 @@ void Runtime::formatSettingPopupValue(char* out, size_t out_len) const case 4: if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) { - std::snprintf(out, out_len, "CH %u", static_cast(cfg.meshtastic_config.channel_num)); + formatMeshtasticChannelSlot(out, out_len, cfg.meshtastic_config.channel_num); } else { @@ -4241,6 +4556,9 @@ void Runtime::formatSettingPopupValue(char* out, size_t out_len) const std::snprintf(out, out_len, "%lus", static_cast(setting_popup_config_.gps_interval_ms / 1000UL)); return; case 4: + formatScreenTimeoutLabel(out, out_len, setting_popup_screen_timeout_ms_); + return; + case 5: { char tz_label[16] = {}; formatTimezoneLabel(setting_popup_timezone_min_, tz_label, sizeof(tz_label)); @@ -4649,6 +4967,85 @@ void Runtime::formatTimestamp(char* out, size_t out_len, uint32_t timestamp_s) c tm->tm_min); } +void Runtime::formatConversationTime(char* out, size_t out_len, uint32_t timestamp_s, bool expanded) const +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (timestamp_s == 0) + { + std::snprintf(out, out_len, "%s", "--:--"); + return; + } + + if (timestamp_s >= 1700000000U) + { + const int tz_offset_s = (host_.timezone_offset_min_fn ? host_.timezone_offset_min_fn() : 0) * 60; + const time_t adjusted = static_cast(timestamp_s + tz_offset_s); + const std::tm* tm = std::gmtime(&adjusted); + if (tm) + { + if (expanded) + { + std::snprintf(out, out_len, "%02d/%02d %02d:%02d", + tm->tm_mon + 1, + tm->tm_mday, + tm->tm_hour, + tm->tm_min); + } + else + { + std::snprintf(out, out_len, "%02d/%02d", tm->tm_mon + 1, tm->tm_mday); + } + return; + } + } + + const unsigned long hours = static_cast((timestamp_s / 3600U) % 24U); + const unsigned long minutes = static_cast((timestamp_s / 60U) % 60U); + if (expanded) + { + std::snprintf(out, out_len, "--/-- %02lu:%02lu", hours, minutes); + } + else + { + std::snprintf(out, out_len, "%02lu:%02lu", hours, minutes); + } +} + +void Runtime::formatConversationSender(char* out, size_t out_len, const chat::ChatMessage& msg, bool expanded) const +{ + if (!out || out_len == 0) + { + return; + } + + if (msg.from == 0) + { + const chat::NodeId self_id = app() ? app()->getSelfNodeId() : 0; + if (expanded && self_id != 0) + { + std::snprintf(out, out_len, "%08lX", static_cast(self_id)); + } + else + { + std::snprintf(out, out_len, "%s", "ME"); + } + return; + } + + if (expanded) + { + std::snprintf(out, out_len, "%08lX", static_cast(msg.from)); + } + else + { + std::snprintf(out, out_len, "%04lX", static_cast(msg.from & 0xFFFFUL)); + } +} + void Runtime::formatProtocol(char* out, size_t out_len) const { if (!out || out_len == 0 || !app()) @@ -4769,7 +5166,7 @@ void Runtime::drawConversationText(int x, int y, int w, const char* text, bool s if (full_width <= w) { const int draw_x = align_right ? (x + std::max(0, w - full_width)) : x; - text_renderer_.drawText(display_, draw_x, y, text, selected); + text_renderer_.drawText(display_, draw_x, y, text, false); return; } @@ -4789,7 +5186,9 @@ void Runtime::drawConversationText(int x, int y, int w, const char* text, bool s std::memcpy(clipped, text, std::min(keep_bytes, sizeof(clipped) - 1)); clipped[std::min(keep_bytes, sizeof(clipped) - 1)] = '\0'; } - text_renderer_.drawText(display_, x, y, clipped, false); + const int clipped_width = text_renderer_.measureTextWidth(clipped); + const int draw_x = align_right ? (x + std::max(0, w - clipped_width)) : x; + text_renderer_.drawText(display_, draw_x, y, clipped, false); return; } @@ -4833,7 +5232,52 @@ void Runtime::drawConversationText(int x, int y, int w, const char* text, bool s const size_t keep_bytes = text_renderer_.clipTextToWidth(window_text, w); std::memcpy(clipped, window_text, std::min(keep_bytes, sizeof(clipped) - 1)); clipped[std::min(keep_bytes, sizeof(clipped) - 1)] = '\0'; - text_renderer_.drawText(display_, x, y, clipped, true); + const int clipped_width = text_renderer_.measureTextWidth(clipped); + const int draw_x = align_right ? (x + std::max(0, w - clipped_width)) : x; + text_renderer_.drawText(display_, draw_x, y, clipped, false); +} + +void Runtime::drawConversationBubble(int x, int y, int w, const char* sender, const char* time_text, + const char* text, bool selected, bool align_right) +{ + if (!text || w <= 4) + { + return; + } + + const int line_h = text_renderer_.lineHeight(); + constexpr int kBubbleBodyBottomPadding = 2; + const int bubble_h = (line_h * 2) + 1 + kBubbleBodyBottomPadding; + drawFrame(display_, x, y, w, bubble_h); + + const int inner_x = x + 1; + const int inner_w = w - 2; + const int divider_y = y + line_h; + const int band_h = std::max(1, line_h - 1); + display_.fillRect(inner_x, divider_y, inner_w, 1, true); + + display_.fillRect(inner_x, y + 1, inner_w, band_h, true); + + const bool header_inverse = true; + const int header_pad = 1; + const int header_x = inner_x + header_pad; + const int header_w = std::max(0, inner_w - (header_pad * 2)); + const int time_w = (time_text && time_text[0] != '\0') ? text_renderer_.measureTextWidth(time_text) : 0; + const int time_x = x + w - 2 - header_pad - time_w; + const int sender_w = std::max(0, time_x - header_x - 2); + + if (sender && sender[0] != '\0' && sender_w > 0) + { + drawTextClipped(header_x, y, sender_w, sender, header_inverse); + } + if (time_text && time_text[0] != '\0' && header_w > 0) + { + text_renderer_.drawText(display_, std::max(header_x, time_x), y, time_text, header_inverse); + } + + const int body_x = inner_x + 1; + const int body_w = std::max(0, inner_w - 2); + drawConversationText(body_x, y + line_h + 1, body_w, text, selected, align_right); } void Runtime::executeActionPageItem(size_t index) @@ -4933,11 +5377,43 @@ const chat::contacts::NodeInfo* Runtime::selectedNode() const return &nodes_[index]; } +size_t Runtime::nodeActionCount() const +{ + const bool meshtastic_mode = app() && app()->getConfig().mesh_protocol != chat::MeshProtocol::MeshCore; + return meshtastic_mode ? kNodeActionItemCount : (kNodeActionItemCount - 1U); +} + +const char* Runtime::nodeActionLabel(size_t index) const +{ + const chat::contacts::NodeInfo* node = selectedNode(); + const bool meshtastic_mode = app() && app()->getConfig().mesh_protocol != chat::MeshProtocol::MeshCore; + switch (index) + { + case 0: + return "DETAIL"; + case 1: + return "REPLY"; + case 2: + return "ADD CONTACT"; + case 3: + return (node && node->is_ignored) ? "UNIGNORE NODE" : "IGNORE NODE"; + case 4: + return "TRACE ROUTE"; + case 5: + return meshtastic_mode ? "EXCHANGE POSITION" : "OPEN COMPASS"; + case 6: + return meshtastic_mode ? "OPEN COMPASS" : ""; + default: + return ""; + } +} + void Runtime::executeNodeAction() { auto* mesh = app() ? app()->getMeshAdapter() : nullptr; auto* contacts = app() ? &app()->getContactService() : nullptr; const chat::contacts::NodeInfo* node = selectedNode(); + const bool meshtastic_mode = app() && app()->getConfig().mesh_protocol != chat::MeshProtocol::MeshCore; if (!node) { appendBootLog("node action na"); @@ -4952,6 +5428,16 @@ void Runtime::executeNodeAction() enterPage(Page::NodeInfo); return; case 1: + { + active_conversation_ = chat::ConversationId(chat::ChannelId::PRIMARY, + node->node_id, + chat::infra::meshProtocolFromRaw( + static_cast(node->protocol), + app()->getConfig().mesh_protocol)); + openCompose(EditTarget::Message); + return; + } + case 2: { if (!contacts) { @@ -4967,6 +5453,7 @@ void Runtime::executeNodeAction() } char nickname[13] = {}; + char fallback_nickname[13] = {}; if (node->short_name[0] != '\0') { copyText(nickname, node->short_name); @@ -4976,8 +5463,16 @@ void Runtime::executeNodeAction() std::snprintf(nickname, sizeof(nickname), "%04lX", static_cast(node->node_id & 0xFFFFUL)); } + std::snprintf(fallback_nickname, sizeof(fallback_nickname), "%04lX", + static_cast(node->node_id & 0xFFFFUL)); - if (contacts->addContact(node->node_id, nickname)) + bool added = contacts->addContact(node->node_id, nickname); + if (!added && std::strcmp(nickname, fallback_nickname) != 0) + { + added = contacts->addContact(node->node_id, fallback_nickname); + } + + if (added) { appendBootLog("contact added"); showTransientPopup("ADD CONTACT", "SUCCESS"); @@ -4991,7 +5486,26 @@ void Runtime::executeNodeAction() } return; } - case 2: + case 3: + { + if (!contacts) + { + appendBootLog("ignore na"); + showTransientPopup("IGNORE NODE", "UNAVAILABLE"); + return; + } + const bool ignored = !node->is_ignored; + const bool ok = contacts->setNodeIgnored(node->node_id, ignored); + appendBootLog(ok ? (ignored ? "node ignored" : "node unignored") : "ignore failed"); + showTransientPopup(ignored ? "IGNORE NODE" : "UNIGNORE NODE", ok ? "SUCCESS" : "FAILED"); + if (ok) + { + rebuildNodeList(); + enterPage(Page::NodeList); + } + return; + } + case 4: { if (!mesh) { @@ -5021,23 +5535,16 @@ void Runtime::executeNodeAction() showTransientPopup("TRACE ROUTE", ok ? "QUEUED" : "FAILED"); return; } - case 3: - { - if (!mesh) + case 5: + if (meshtastic_mode) { - appendBootLog("verify na"); - showTransientPopup("KEY VERIFICATION", "UNAVAILABLE"); + requestNodePositionExchange(); return; } - const bool ok = mesh->startKeyVerification(node->node_id); - appendBootLog(ok ? "verify queued" : "verify failed"); - showTransientPopup("KEY VERIFICATION", ok ? "QUEUED" : "FAILED"); + showTransientPopup("OPEN COMPASS", "OPENED"); + enterPage(Page::NodeCompass); return; - } - case 4: - requestNodePositionExchange(); - return; - case 5: + case 6: showTransientPopup("OPEN COMPASS", "OPENED"); enterPage(Page::NodeCompass); return; diff --git a/modules/ui_shared/include/ui/chat_ui_runtime_proxy.h b/modules/ui_shared/include/ui/chat_ui_runtime_proxy.h new file mode 100644 index 00000000..10d7f632 --- /dev/null +++ b/modules/ui_shared/include/ui/chat_ui_runtime_proxy.h @@ -0,0 +1,26 @@ +#pragma once + +#include "ui/chat_ui_runtime.h" + +namespace chat::ui +{ + +class GlobalChatUiRuntime final : public IChatUiRuntime +{ + public: + GlobalChatUiRuntime(); + ~GlobalChatUiRuntime() override; + + void setActiveRuntime(IChatUiRuntime* runtime); + IChatUiRuntime* getActiveRuntime() const; + + void update() override; + void onChatEvent(sys::Event* event) override; + ChatUiState getState() const override; + bool isTeamConversationActive() const override; + + private: + IChatUiRuntime* active_runtime_ = nullptr; +}; + +} // namespace chat::ui diff --git a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h index ce0b767a..568319b7 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h @@ -33,6 +33,7 @@ class ChatConversationScreen void addMessage(const chat::ChatMessage& msg); void clearMessages(); void scrollToBottom(); + bool updateMessageStatus(chat::MessageId msg_id, chat::MessageStatus status); void setActionCallback(void (*cb)(ActionIntent intent, void*), void* user_data); bool isAlive() const { return guard_ && guard_->alive; } diff --git a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h index 8d34f466..be9ef955 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h @@ -140,6 +140,8 @@ class ChatMessageListScreen message_list::input::Controller input_controller_{}; void rebuildList(); + bool updateListInPlace(const std::vector& convs); + void updateListItem(size_t index, const chat::ConversationMeta& conv); void updateFilterHighlight(); void setFilterMode(FilterMode mode); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h index 8235adbf..29d5784e 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h @@ -75,8 +75,15 @@ class UiController : public IChatUiRuntime void handleChannelSelected(const chat::ConversationId& conv); void handleSendMessage(const std::string& text); void refreshUnreadCounts(); + void refreshUnreadCounts(bool force_reload); void cleanupComposeIme(); bool isTeamConversation(const chat::ConversationId& conv) const; + void syncConversationListFromStore(); + void normalizeConversationNames(std::vector& convs) const; + void applyConversationListToUi(); + void updateConversationMetaForMessage(const chat::ChatMessage& msg, bool increment_unread); + bool updateConversationViewForIncoming(const chat::ChatMessage& msg); + void reloadConversationView(); void refreshTeamConversation(); void startTeamConversationTimer(); void stopTeamConversationTimer(); @@ -87,6 +94,14 @@ class UiController : public IChatUiRuntime void onTeamPositionIconSelected(uint8_t icon_id); void onTeamPositionCancel(); bool isTeamPositionPickerOpen() const; + bool isKeyVerificationModalOpen() const; + void openKeyVerificationNumberModal(chat::NodeId node_id, uint64_t nonce); + void openKeyVerificationInfoModal(chat::NodeId node_id, uint32_t number); + void openKeyVerificationFinalModal(chat::NodeId node_id, const char* code, bool is_sender); + void closeKeyVerificationModal(bool restore_group); + void submitKeyVerificationNumber(); + void trustKeyFromVerificationModal(); + void clearKeyVerificationError(); struct TeamPositionIconEventCtx { @@ -101,8 +116,26 @@ class UiController : public IChatUiRuntime lv_group_t* team_position_picker_group_ = nullptr; lv_group_t* team_position_prev_group_ = nullptr; + lv_obj_t* key_verify_overlay_ = nullptr; + lv_obj_t* key_verify_panel_ = nullptr; + lv_obj_t* key_verify_desc_ = nullptr; + lv_obj_t* key_verify_textarea_ = nullptr; + lv_obj_t* key_verify_error_label_ = nullptr; + lv_group_t* key_verify_group_ = nullptr; + lv_group_t* key_verify_prev_group_ = nullptr; + std::unique_ptr<::ui::widgets::ImeWidget> key_verify_ime_; + chat::NodeId key_verify_node_id_ = 0; + uint64_t key_verify_nonce_ = 0; + bool key_verify_expects_number_ = false; + bool key_verify_can_trust_ = false; + std::vector cached_conversations_; + bool conversation_list_dirty_ = true; + static void team_position_icon_event_cb(lv_event_t* e); static void team_position_cancel_event_cb(lv_event_t* e); + static void key_verify_submit_event_cb(lv_event_t* e); + static void key_verify_close_event_cb(lv_event_t* e); + static void key_verify_trust_event_cb(lv_event_t* e); }; } // namespace ui diff --git a/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h b/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h index 1434e5dd..7f8aed17 100644 --- a/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h +++ b/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h @@ -21,6 +21,7 @@ class ChatConversationScreen void addMessage(const chat::ChatMessage& msg); void clearMessages(); void scrollToBottom(); + bool updateMessageStatus(chat::MessageId msg_id, chat::MessageStatus status); void setActionCallback(void (*cb)(ActionIntent intent, void*), void* user_data); bool isAlive() const { return guard_ && guard_->alive; } diff --git a/modules/ui_shared/include/ui/screens/contacts/contacts_state.h b/modules/ui_shared/include/ui/screens/contacts/contacts_state.h index 54bf52de..da5a6914 100644 --- a/modules/ui_shared/include/ui/screens/contacts/contacts_state.h +++ b/modules/ui_shared/include/ui/screens/contacts/contacts_state.h @@ -42,6 +42,7 @@ enum class ContactsMode { Contacts, // Show contacts (nodes with nicknames) Nearby, // Show nearby nodes (nodes without nicknames) + Ignored, // Show ignored nodes so they can be managed/unignored Broadcast, // Show broadcast channels Team, // Show team (if joined) Discover // Show MeshCore discover actions @@ -58,6 +59,7 @@ struct ContactsPageState lv_obj_t* filter_panel = nullptr; lv_obj_t* contacts_btn = nullptr; lv_obj_t* nearby_btn = nullptr; + lv_obj_t* ignored_btn = nullptr; lv_obj_t* broadcast_btn = nullptr; lv_obj_t* team_btn = nullptr; lv_obj_t* discover_btn = nullptr; @@ -81,6 +83,7 @@ struct ContactsPageState // Data (using forward declaration, full type in .cpp) std::vector contacts_list; std::vector nearby_list; + std::vector ignored_list; // Timers lv_timer_t* refresh_timer = nullptr; 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 1db1b299..a1940c88 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -108,9 +108,11 @@ struct SettingsData // Screen int screen_timeout_ms = 30000; + int screen_brightness = 16; int timezone_offset_min = 0; int speaker_volume = 45; bool ble_enabled = true; + bool vibration_enabled = true; // Power / Gauge (System) char gauge_design_mah[8] = ""; diff --git a/modules/ui_shared/src/ui/assets/aprs.c b/modules/ui_shared/src/ui/assets/aprs.c new file mode 100644 index 00000000..4ac4f590 --- /dev/null +++ b/modules/ui_shared/src/ui/assets/aprs.c @@ -0,0 +1,165 @@ +#ifdef __has_include + #if __has_include("lvgl.h") + #ifndef LV_LVGL_H_INCLUDE_SIMPLE + #define LV_LVGL_H_INCLUDE_SIMPLE + #endif + #endif +#endif + +#if defined(LV_LVGL_H_INCLUDE_SIMPLE) + #include "lvgl.h" +#else + #include "lvgl/lvgl.h" +#endif + + +#ifndef LV_ATTRIBUTE_MEM_ALIGN +#define LV_ATTRIBUTE_MEM_ALIGN +#endif + +#ifndef LV_ATTRIBUTE_IMAGE_APRS +#define LV_ATTRIBUTE_IMAGE_APRS +#endif + +const LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST LV_ATTRIBUTE_IMAGE_APRS uint8_t aprs_map[] = { + 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, 0x8e, 0x32, 0xae, 0x32, 0x4d, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xcb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0x4d, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0x4d, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x4d, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xed, 0x7b, 0xae, 0x32, 0x0d, 0x53, 0x8b, 0x42, 0x8b, 0x4a, 0x0b, 0x63, 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, 0x9f, 0x65, 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, 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, 0x4a, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x6d, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 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, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 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, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 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, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1e, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x00, 0x00, 0x1f, 0xe7, 0xbf, 0x75, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x1d, 0x5d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xce, 0x42, 0xee, 0x42, 0xee, 0x42, 0xee, 0x42, 0xee, 0x42, 0xae, 0x3a, 0xae, 0x3a, 0xae, 0x32, 0xae, 0x32, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xaf, 0x3a, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xcb, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 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, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6e, 0x32, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x0c, 0x2a, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x2c, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x2d, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x1e, 0xe7, 0x1e, 0xe7, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2d, 0x2a, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1e, 0xe7, 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, 0x00, 0x00, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 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, 0x00, 0x00, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0xae, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 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, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x4d, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x0c, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1e, 0xe7, 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, 0x00, 0x00, 0x00, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x0c, 0x22, 0x4d, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1e, 0xe7, 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, 0x00, 0x00, 0x00, 0x9f, 0x6d, 0x9f, 0x65, 0x9f, 0x65, 0x9f, 0x65, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1e, 0xe7, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x2c, 0x2a, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 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, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, + 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 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, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, + 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, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 0x1f, 0xe7, 0x1f, 0xe7, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x4f, 0x43, 0x11, 0x7e, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x72, 0x4e, 0x92, 0x4e, 0xb1, 0x44, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xae, 0x3a, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x31, 0x7e, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x13, 0x67, 0xf3, 0x56, 0xf3, 0x56, 0x2f, 0x3b, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x72, 0x86, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x33, 0x7f, 0xf3, 0x56, 0xf3, 0x56, 0x32, 0x4e, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x72, 0x86, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x99, 0xcf, 0x74, 0xa7, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x91, 0x86, 0xaf, 0x5c, 0xaf, 0x5c, 0xef, 0x64, 0x32, 0x87, 0xf3, 0x56, 0xf3, 0x56, 0x92, 0x4e, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x72, 0x86, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x74, 0xa7, 0x52, 0x97, 0x52, 0x97, 0x74, 0xa7, 0xdf, 0xff, 0xbe, 0xf7, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0xec, 0x3a, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x51, 0x76, 0xf3, 0x56, 0xf3, 0x56, 0x92, 0x4e, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x11, 0x7e, 0x52, 0x97, 0x52, 0x97, 0x97, 0xbf, 0xdf, 0xff, 0x96, 0xb7, 0x52, 0x97, 0x96, 0xb7, 0xdf, 0xff, 0xbe, 0xf7, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x91, 0x86, 0xaf, 0x5c, 0xaf, 0x5c, 0xd0, 0x75, 0x52, 0x97, 0xf3, 0x56, 0xf3, 0x56, 0x92, 0x4e, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xe7, 0x1f, 0xe7, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xd1, 0x75, 0x52, 0x97, 0x52, 0x97, 0x96, 0xb7, 0xdf, 0xff, 0x99, 0xcf, 0x52, 0x97, 0x76, 0xb7, 0xdf, 0xff, 0xbe, 0xf7, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x8f, 0xf3, 0x56, 0xf3, 0x56, 0x92, 0x4e, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xd1, 0x75, 0x52, 0x97, 0x52, 0x97, 0x74, 0xa7, 0x99, 0xcf, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x97, 0xbf, 0x76, 0xb7, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x8f, 0xf3, 0x56, 0xf3, 0x56, 0x32, 0x4e, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 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, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xd1, 0x75, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x32, 0x7f, 0xf3, 0x56, 0xf3, 0x56, 0x91, 0x4d, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xf0, 0x64, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0x52, 0x97, 0xf3, 0x5e, 0xf3, 0x56, 0xf3, 0x56, 0x91, 0x4d, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xff, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x2f, 0x43, 0x11, 0x7e, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0x12, 0x8f, 0xd2, 0x86, 0xd3, 0x56, 0xd3, 0x56, 0x91, 0x4d, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xdf, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x66, 0xdf, 0x66, 0xff, 0x66, 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, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xe7, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xed, 0x7b, 0x29, 0xfe, 0x29, 0xfe, 0xea, 0xf5, 0xea, 0xf5, 0xea, 0xf5, 0xea, 0xf5, 0xea, 0xf5, 0xeb, 0xb4, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6c, 0x9c, 0x29, 0xfe, 0x29, 0xfe, 0x29, 0xfe, 0x29, 0xfe, 0xea, 0xf5, 0xea, 0xf5, 0xea, 0xf5, 0x4c, 0x94, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8a, 0xdd, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0xea, 0xf5, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x29, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x8a, 0xdd, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8a, 0xdd, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0xea, 0xf5, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x29, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x8a, 0xdd, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8a, 0xdd, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0xca, 0xed, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x0a, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x49, 0xfe, 0x6a, 0xd5, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x0e, 0x53, 0x6c, 0x9c, 0x6c, 0x9c, 0x6c, 0x9c, 0x6c, 0x9c, 0x6c, 0x9c, 0x6c, 0x9c, 0x6c, 0x9c, 0x2e, 0x53, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x2e, 0x53, 0x6c, 0x9c, 0x6c, 0x9c, 0x6c, 0x9c, 0x6c, 0x9c, 0x0b, 0xbd, 0x0b, 0xbd, 0x0b, 0xbd, 0x0e, 0x53, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x8e, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x6d, 0x32, 0x0c, 0x22, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x0c, 0x22, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x2c, 0x2a, 0x0c, 0x22, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6d, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8e, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x6e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 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, 0x8e, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0xae, 0x32, 0x8e, 0x32, 0xeb, 0x21, 0xeb, 0x21, 0xeb, 0x21, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x9f, 0xaf, 0xff, 0xbf, 0x20, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0xc3, 0xff, 0xff, 0x60, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xff, 0xc3, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xc3, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xe3, 0xff, 0xff, 0x9f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xe3, 0xff, 0xff, 0x7f, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb7, 0xdf, 0xe7, 0xe3, 0xc7, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xdf, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xbf, 0xbf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xdf, 0xff, 0xff, 0x7f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xff, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xbf, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xdf, 0xff, 0xdf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x20, 0xbf, 0xff, 0xff, 0xff, 0x9f, 0x20, 0x00, 0x60, 0xcf, 0xff, 0xff, 0xff, 0xab, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0xff, 0xdf, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xbf, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xab, 0xcf, 0xcf, 0xcf, 0xcf, 0xe3, 0xe3, 0xdf, 0xdf, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xff, 0xff, 0xff, 0xcf, 0xdf, 0x9f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xbf, 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, 0xff, 0xff, 0xff, 0xe6, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xcb, 0xe5, 0xff, 0xff, 0xff, 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, 0x9f, 0xdf, 0xdf, 0xff, 0xff, 0xff, 0xd1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcb, 0xe3, 0xff, 0xff, 0x00, 0x40, 0x40, 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, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe4, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xbd, 0xcd, 0xff, 0xe3, 0xff, 0xff, 0x00, 0xff, 0xff, 0x7f, 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, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xdf, 0xff, 0xff, 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, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xdf, 0xff, 0xff, 0xdf, 0x00, 0xdf, 0xff, 0xff, 0x20, 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, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc7, 0xff, 0xff, 0xdf, 0x00, 0xdf, 0xff, 0xff, 0x40, 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, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xb7, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xd7, 0xc0, 0xff, 0xff, 0xe3, 0x00, 0xdf, 0xff, 0xff, 0x40, 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, 0x40, 0xbf, 0xbf, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xe3, 0x00, 0xdf, 0xff, 0xff, 0x40, 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, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xe3, 0x20, 0xdf, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xbf, 0xff, 0xff, 0xdf, 0xdf, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0x20, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xdf, 0xdf, 0xdf, 0x5f, + 0x9f, 0xdf, 0xdf, 0xdf, 0xff, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xcf, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xe3, 0xe3, 0xcf, 0xcf, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0x77, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xcf, 0xcf, 0xe3, 0xe3, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xff, 0x60, 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, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xe3, 0x60, 0x20, 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, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xe3, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xaf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xb7, 0xcf, 0xc3, 0xff, 0xcf, 0xff, 0xff, 0xe3, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe7, 0xff, 0xff, 0xe3, 0xcf, 0xff, 0xff, 0xdf, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xc3, 0xcf, 0xff, 0xff, 0xdf, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xc3, 0xc3, 0xd7, 0xaf, 0xff, 0xff, 0xcf, 0xcf, 0xff, 0xff, 0xdf, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xc7, 0xff, 0xff, 0xc7, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xb9, 0xff, 0xff, 0xcf, 0xcf, 0xff, 0xff, 0xdf, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaf, 0xff, 0xff, 0xb7, 0xff, 0xcf, 0xff, 0xcf, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xc3, 0xc3, 0xe7, 0xcf, 0xff, 0xff, 0xcf, 0xcf, 0xff, 0xff, 0xdf, 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, 0x7f, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xcf, 0xff, 0xc3, 0xff, 0xe7, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xcf, 0xcf, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xe3, 0xc3, 0xff, 0xff, 0xff, 0xb7, 0xe7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xc3, 0xcf, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xbf, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xbf, 0xcf, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xff, 0x9f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0xbf, 0xff, 0xff, 0xdf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xbf, 0xcf, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xdf, 0xff, 0xdf, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xc0, 0xe3, 0xe3, 0xbf, 0xff, 0xcf, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xbf, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xdf, 0x9f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xdf, 0x60, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xdf, 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, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xb7, 0xe3, 0xe3, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xe3, 0xe3, 0xe3, 0xe3, 0xcf, 0xcf, 0xcf, 0xd7, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xb7, 0xff, 0xff, 0xff, 0xff, 0xc7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xc3, 0xe3, 0xff, 0xff, 0xff, 0xff, 0xc7, 0xc3, 0xc3, 0xc3, 0xc3, 0xbf, 0xbf, 0xbf, 0xff, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xdf, 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, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xc3, 0xff, 0xff, 0xdf, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 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, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xe3, 0xc7, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xcf, 0xe3, 0xff, 0xff, 0x9f, 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, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0x60, 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, 0x20, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xdf, 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, 0x20, 0x7f, 0xbf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xcf, 0xdf, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const lv_image_dsc_t aprs = { + .header.magic = LV_IMAGE_HEADER_MAGIC, + .header.cf = LV_COLOR_FORMAT_RGB565A8, + .header.flags = 0, + .header.w = 64, + .header.h = 64, + .header.stride = 128, + .data_size = sizeof(aprs_map), + .data = aprs_map, +}; diff --git a/modules/ui_shared/src/ui/chat_ui_runtime_proxy.cpp b/modules/ui_shared/src/ui/chat_ui_runtime_proxy.cpp new file mode 100644 index 00000000..cb32060c --- /dev/null +++ b/modules/ui_shared/src/ui/chat_ui_runtime_proxy.cpp @@ -0,0 +1,71 @@ +#include "ui/chat_ui_runtime_proxy.h" + +#include "sys/event_bus.h" + +namespace chat::ui +{ +namespace +{ +bool isKeyVerificationEvent(sys::EventType type) +{ + return type == sys::EventType::KeyVerificationNumberRequest || + type == sys::EventType::KeyVerificationNumberInform || + type == sys::EventType::KeyVerificationFinal; +} +} // namespace + +GlobalChatUiRuntime::GlobalChatUiRuntime() = default; + +GlobalChatUiRuntime::~GlobalChatUiRuntime() = default; + +void GlobalChatUiRuntime::setActiveRuntime(IChatUiRuntime* runtime) +{ + active_runtime_ = runtime; +} + +IChatUiRuntime* GlobalChatUiRuntime::getActiveRuntime() const +{ + return active_runtime_; +} + +void GlobalChatUiRuntime::update() +{ + if (active_runtime_) + { + active_runtime_->update(); + } +} + +void GlobalChatUiRuntime::onChatEvent(sys::Event* event) +{ + if (!event) + { + return; + } + + if (active_runtime_) + { + active_runtime_->onChatEvent(event); + return; + } + + if (isKeyVerificationEvent(event->type)) + { + delete event; + return; + } + + delete event; +} + +ChatUiState GlobalChatUiRuntime::getState() const +{ + return active_runtime_ ? active_runtime_->getState() : ChatUiState::ChannelList; +} + +bool GlobalChatUiRuntime::isTeamConversationActive() const +{ + return active_runtime_ ? active_runtime_->isTeamConversationActive() : false; +} + +} // namespace chat::ui 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 16096d71..64406e81 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 @@ -7,6 +7,7 @@ #include #include "platform/ui/gps_runtime.h" +#include "sys/clock.h" #include "ui/menu/dashboard/dashboard_state.h" namespace ui::menu::dashboard @@ -14,6 +15,41 @@ namespace ui::menu::dashboard namespace { +void logDashboardGpsFlow(bool has_snapshot, std::size_t sat_count, const gps::GnssStatus& gnss, bool fix) +{ + static bool s_last_has_snapshot = false; + static bool s_last_fix = false; + static std::size_t s_last_sat_count = static_cast(-1); + static uint8_t s_last_sats_in_view = 0xFF; + static uint8_t s_last_sats_in_use = 0xFF; + static uint32_t s_last_log_ms = 0; + + const uint32_t now_ms = sys::millis_now(); + const bool changed = (has_snapshot != s_last_has_snapshot) || (fix != s_last_fix) || (sat_count != s_last_sat_count) || + (gnss.sats_in_view != s_last_sats_in_view) || (gnss.sats_in_use != s_last_sats_in_use); + const bool suspicious_full_scale = + has_snapshot && sat_count == gps::kMaxGnssSats && gnss.sats_in_view != static_cast(sat_count); + if (!changed && !suspicious_full_scale && (now_ms - s_last_log_ms) < 2000U) + { + return; + } + + s_last_has_snapshot = has_snapshot; + s_last_fix = fix; + s_last_sat_count = sat_count; + s_last_sats_in_view = gnss.sats_in_view; + s_last_sats_in_use = gnss.sats_in_use; + s_last_log_ms = now_ms; + + std::printf("[ui][gps][dashboard] has=%u fix=%u count=%u used=%u view=%u hdop=%.1f\n", + static_cast(has_snapshot ? 1 : 0), + static_cast(fix ? 1 : 0), + static_cast(sat_count), + static_cast(gnss.sats_in_use), + static_cast(gnss.sats_in_view), + static_cast(gnss.hdop)); +} + void set_label_text_if_changed(lv_obj_t* label, const char* text) { if (label == nullptr || text == nullptr) @@ -184,6 +220,7 @@ void refresh_gps_widget() const bool has_snapshot = platform::ui::gps::get_gnss_snapshot(sats, gps::kMaxGnssSats, &sat_count, &gnss); const auto state = platform::ui::gps::get_data(); const bool fix = state.valid || (has_snapshot && gnss.fix != gps::GnssFix::NOFIX); + logDashboardGpsFlow(has_snapshot, sat_count, gnss, fix); set_status_chip(gps_ui.chrome, fix_text(gnss.fix), @@ -194,7 +231,7 @@ void refresh_gps_widget() char used_buf[16]; std::snprintf(used_buf, sizeof(used_buf), "%u/%u", - static_cast(gnss.sats_in_use), + static_cast(has_snapshot ? gnss.sats_in_use : 0), static_cast(has_snapshot ? sat_count : 0)); set_label_text_if_changed(gps_ui.stat_values[1], used_buf); 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 cb5aebe9..935a7e68 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 @@ -179,13 +179,16 @@ void refresh_recent_widget() std::snprintf(footer, sizeof(footer), "%u threads active", static_cast(total)); set_label_text_if_changed(recent.footer_label, footer); + // Keep the footer accent subtle; continuously animating it forces a redraw + // every dashboard tick even when no conversation state changed. const lv_coord_t track_w = lv_obj_get_width(recent.scan_track); const lv_coord_t runner_w = lv_obj_get_width(recent.scan_runner); const lv_coord_t travel = track_w - runner_w; - const lv_coord_t x = travel > 0 - ? static_cast(((dashboard_state().tick % 2U) == 0U) ? (travel / 3) : ((travel * 2) / 3)) - : 0; - lv_obj_set_x(recent.scan_runner, x); + const lv_coord_t x = travel > 0 ? travel / 2 : 0; + if (lv_obj_get_x(recent.scan_runner) != x) + { + lv_obj_set_x(recent.scan_runner, x); + } } } // namespace ui::menu::dashboard 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 0e898b77..11397402 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_style.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace ui::menu::dashboard { @@ -98,7 +99,8 @@ DashboardCardChrome create_card_chrome(lv_obj_t* parent, chrome.body = lv_obj_create(chrome.card); lv_obj_set_size(chrome.body, LV_PCT(100), card_h - kHeaderH - kFooterH); lv_obj_align_to(chrome.body, chrome.header, LV_ALIGN_OUT_BOTTOM_MID, 0, 0); - lv_obj_set_style_bg_opa(chrome.body, LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color(chrome.body, color_panel_bg(), 0); + lv_obj_set_style_bg_opa(chrome.body, LV_OPA_COVER, 0); lv_obj_set_style_border_width(chrome.body, 0, 0); lv_obj_set_style_pad_left(chrome.body, 10, 0); lv_obj_set_style_pad_right(chrome.body, 10, 0); @@ -110,7 +112,7 @@ DashboardCardChrome create_card_chrome(lv_obj_t* parent, lv_obj_set_size(chrome.footer, LV_PCT(100), kFooterH); lv_obj_align(chrome.footer, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_set_style_bg_color(chrome.footer, color_soft_amber(), 0); - lv_obj_set_style_bg_opa(chrome.footer, LV_OPA_50, 0); + lv_obj_set_style_bg_opa(chrome.footer, LV_OPA_COVER, 0); lv_obj_set_style_border_width(chrome.footer, 0, 0); lv_obj_set_style_radius(chrome.footer, 18, 0); lv_obj_set_style_pad_left(chrome.footer, 10, 0); @@ -128,10 +130,23 @@ void set_status_chip(DashboardCardChrome& chrome, const char* text, lv_color_t b { return; } - lv_obj_set_style_bg_color(chrome.status_chip, bg, 0); - lv_obj_set_style_bg_opa(chrome.status_chip, LV_OPA_COVER, 0); - lv_obj_set_style_text_color(chrome.status_label, fg, 0); - lv_label_set_text(chrome.status_label, text); + if (lv_color_to_int(lv_obj_get_style_bg_color(chrome.status_chip, LV_PART_MAIN)) != lv_color_to_int(bg)) + { + lv_obj_set_style_bg_color(chrome.status_chip, bg, 0); + } + if (lv_obj_get_style_bg_opa(chrome.status_chip, LV_PART_MAIN) != LV_OPA_COVER) + { + lv_obj_set_style_bg_opa(chrome.status_chip, LV_OPA_COVER, 0); + } + if (lv_color_to_int(lv_obj_get_style_text_color(chrome.status_label, LV_PART_MAIN)) != lv_color_to_int(fg)) + { + lv_obj_set_style_text_color(chrome.status_label, fg, 0); + } + const char* current = lv_label_get_text(chrome.status_label); + if (text != nullptr && (current == nullptr || std::strcmp(current, text) != 0)) + { + lv_label_set_text(chrome.status_label, text); + } } void style_stat_tile(lv_obj_t* tile, lv_color_t bg) diff --git a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp index 11b2e4bc..1991e6e1 100644 --- a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp +++ b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp @@ -10,13 +10,17 @@ #include "ui/menu/dashboard/dashboard_state.h" #include "ui/menu/dashboard/dashboard_widgets.h" #include "ui/menu/menu_profile.h" +#include "ui/ui_theme.h" namespace ui::menu::dashboard { namespace { -constexpr uint32_t kDashboardTimerMs = 220; +// Tab5's 720x1280 menu can starve the LVGL task if we keep animating the +// dashboard aggressively while the menu grid is also visible. A slower cadence +// keeps the dashboard useful without continuously forcing large redraws. +constexpr uint32_t kDashboardTimerMs = 1000; bool is_tab5_profile() { @@ -34,7 +38,6 @@ void refresh_dashboard(lv_timer_t* timer) ++dashboard.tick; - // Keep motion responsive, but avoid repainting the whole dashboard too aggressively. refresh_compass_widget(); if ((dashboard.tick % 2U) == 1U) @@ -68,7 +71,8 @@ void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::Ini dashboard.dock_right = right_gap >= 240; dashboard.panel = lv_obj_create(menu_panel); - lv_obj_set_style_bg_opa(dashboard.panel, LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color(dashboard.panel, ui::theme::page_bg(), 0); + lv_obj_set_style_bg_opa(dashboard.panel, LV_OPA_COVER, 0); lv_obj_set_style_border_width(dashboard.panel, 0, 0); lv_obj_set_style_pad_all(dashboard.panel, 0, 0); lv_obj_set_style_pad_row(dashboard.panel, 12, 0); diff --git a/modules/ui_shared/src/ui/menu/menu_profile.cpp b/modules/ui_shared/src/ui/menu/menu_profile.cpp index ad98eec7..05fb7832 100644 --- a/modules/ui_shared/src/ui/menu/menu_profile.cpp +++ b/modules/ui_shared/src/ui/menu/menu_profile.cpp @@ -13,7 +13,7 @@ namespace ui::menu_profile namespace { -#if !defined(TRAIL_MATE_ESP_BOARD_TAB5) && !defined(ARDUINO_T_LORA_PAGER) && !defined(ARDUINO_T_DECK) +#if !defined(TRAIL_MATE_ESP_BOARD_TAB5) && !defined(ARDUINO_T_LORA_PAGER) && !defined(ARDUINO_T_DECK) && !defined(ARDUINO_T_DECK_PRO) lv_coord_t display_width() { lv_coord_t width = lv_display_get_physical_horizontal_resolution(nullptr); @@ -206,7 +206,7 @@ const MenuLayoutProfile& current() return make_tab5_profile(); #elif defined(ARDUINO_T_LORA_PAGER) return make_pager_profile(); -#elif defined(ARDUINO_T_DECK) +#elif defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) return make_tdeck_profile(); #else return make_default_profile(display_width(), display_height()); diff --git a/modules/ui_shared/src/ui/page/page_profile.cpp b/modules/ui_shared/src/ui/page/page_profile.cpp index 917e5e3e..7e20ed71 100644 --- a/modules/ui_shared/src/ui/page/page_profile.cpp +++ b/modules/ui_shared/src/ui/page/page_profile.cpp @@ -163,7 +163,7 @@ const PageLayoutProfile& current() return make_tab5_profile(); #elif defined(ARDUINO_T_LORA_PAGER) return make_pager_profile(); -#elif defined(ARDUINO_T_DECK) +#elif defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) return make_tdeck_profile(); #else lv_coord_t width = lv_display_get_physical_horizontal_resolution(nullptr); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp index 9337bc53..d9b9f7c2 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp @@ -5,18 +5,30 @@ * * Wireframe (structure only): * - * 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * 鈹?TopBar: [Title] [RSSI] 鈹? * 鈹溾攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * 鈹?Content (grow) 鈹? * 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹? * 鈹? 鈹?TextArea (multi-line, grow) 鈹?鈹? * 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹?鈹? * 鈹溾攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * 鈹?ActionBar: [Send] [Cancel] Len:x 鈹? * 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * + * +----------------------------------------------+ + * | TopBar: [Title] [RSSI] | + * +----------------------------------------------+ + * | Content (grow) | + * | +----------------------------------------+ | + * | | TextArea (multi-line, grow) | | + * | +----------------------------------------+ | + * +----------------------------------------------+ + * | ActionBar: [Send] [Position] [Cancel] Len:x | + * +----------------------------------------------+ + * * Tree view: - * container(root, column) - * 鈹溾攢 top_bar (widget host on container) - * 鈹溾攢 content (column, grow=1, not scrollable) - * 鈹? 鈹斺攢 textarea (grow=1) - * 鈹斺攢 action_bar (row) - * 鈹溾攢 send_btn - * 鈹? 鈹斺攢 send_label - * 鈹溾攢 cancel_btn - * 鈹? 鈹斺攢 cancel_label - * 鈹斺攢 len_label + * - container(root, column) + * - top_bar (widget host on container) + * - content (column, grow=1, not scrollable) + * - textarea (grow=1) + * - action_bar (row) + * - send_btn + * - send_label + * - position_btn + * - position_label + * - cancel_btn + * - cancel_label + * - len_label */ #include "ui/screens/chat/chat_compose_layout.h" 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 1ee6dc2d..ee8b9bd7 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 @@ -344,6 +344,44 @@ void ChatConversationScreen::scrollToBottom() } } +bool ChatConversationScreen::updateMessageStatus(const chat::MessageId msg_id, + const chat::MessageStatus status) +{ + if (!guard_ || !guard_->alive || msg_id == 0) + { + return false; + } + + for (auto& item : messages_) + { + if (item.msg.msg_id != msg_id) + { + continue; + } + item.msg.status = status; + if (!item.status_label) + { + return true; + } + + if (status == MessageStatus::Failed) + { + lv_label_set_text(item.status_label, "Failed"); + ::ui::fonts::apply_ui_chrome_font(item.status_label); + lv_obj_clear_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_label_set_text(item.status_label, ""); + ::ui::fonts::apply_ui_chrome_font(item.status_label); + lv_obj_add_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); + } + return true; + } + + return false; +} + void ChatConversationScreen::setActionCallback(void (*cb)(ActionIntent intent, void*), void* user_data) { if (!guard_ || !guard_->alive) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp index 17c0f2a1..bf8ef5d1 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp @@ -8,14 +8,33 @@ * * Root Container (COLUMN, full screen) * - * 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * 鈹? TopBar widget (fixed height) 鈹? * 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? * 鈹? 鈹?< Back (Title) (Status/...) 鈹? 鈹? * 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? * 鈹? 鈹? * 鈹? Msg List (scrollable V, flex-grow = 1) 鈹? * 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? * 鈹? 鈹?Row (full width, transparent) 鈹? 鈹? * 鈹? 鈹? 鈹斺攢 Bubble (max ~70% width) 鈹? 鈹? * 鈹? 鈹? 鈹斺攢 TextLabel (WRAP) 鈹? 鈹? * 鈹? 鈹?(self -> row align END / other -> row align START) 鈹? 鈹? * 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? * 鈹? 鈹? * 鈹? Action Bar (fixed height=30, non-scrollable) 鈹? * 鈹? 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? * 鈹? 鈹? [ Reply ] 鈹? 鈹? * 鈹? 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? 鈹? * 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * + * +----------------------------------------------------------------+ + * | TopBar widget (fixed height) | + * | +------------------------------------------------------------+ | + * | | < Back (Title) (Status/...) | | + * | +------------------------------------------------------------+ | + * | | + * | Msg List (scrollable V, flex-grow = 1) | + * | +------------------------------------------------------------+ | + * | | Row (full width, transparent) | | + * | | + Bubble (max ~70% width) | | + * | | + TextLabel (WRAP) | | + * | | self -> row align END / other -> row align START | | + * | +------------------------------------------------------------+ | + * | | + * | Action Bar (fixed height = 30, non-scrollable) | + * | +------------------------------------------------------------+ | + * | | [ Reply ] | | + * | +------------------------------------------------------------+ | + * +----------------------------------------------------------------+ + * * Tree view: * Root(COL) - * 鈹溾攢 TopBar(widget) // created by top_bar_init(top_bar_, root) - * 鈹溾攢 MsgList(COL, scroll V, grow=1) - * 鈹? 鈹斺攢 MsgRow*(repeat, ROW, full) - * 鈹? 鈹斺攢 Bubble(COL, content) -> TextLabel(WRAP) - * 鈹斺攢 ActionBar(ROW, fixed=30) -> ReplyBtn -> ReplyLabel + * - TopBar(widget) // created by top_bar_init(top_bar_, root) + * - MsgList(COL, scroll V, grow=1) + * - MsgRow*(repeat, ROW, full) + * - Bubble(COL, content) -> TextLabel(WRAP) + * - ActionBar(ROW, fixed=30) -> ReplyBtn -> ReplyLabel * * Notes: * - Structure/layout only: create objects, set size/flex/align/flags. diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp index 4507bd73..68daeb16 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp @@ -156,6 +156,23 @@ static bool conversation_list_equal(const std::vector& l return true; } +static bool conversation_identity_list_equal(const std::vector& lhs, + const std::vector& rhs) +{ + if (lhs.size() != rhs.size()) + { + return false; + } + for (size_t index = 0; index < lhs.size(); ++index) + { + if (!(lhs[index].id == rhs[index].id)) + { + return false; + } + } + return true; +} + static const char* touch_event_name(lv_event_code_t code) { switch (code) @@ -337,6 +354,7 @@ void ChatMessageListScreen::setConversations(const std::vector previous_convs = convs_; convs_ = convs; if (team_btn_) { @@ -354,7 +372,11 @@ void ChatMessageListScreen::setConversations(const std::vector& convs) +{ + if (!guard_ || !guard_->alive || !list_panel_ || !lv_obj_is_valid(list_panel_)) + { + return false; + } + if (!conversation_identity_list_equal(convs_, convs)) + { + return false; + } + + std::vector filtered; + filtered.reserve(convs.size()); + for (const auto& conv : convs) + { + if (is_team_conversation(conv.id)) + { + if (filter_mode_ == FilterMode::Team) + { + filtered.push_back(conv); + } + continue; + } + if (filter_mode_ == FilterMode::Direct && conv.id.peer != 0) + { + filtered.push_back(conv); + } + else if (filter_mode_ == FilterMode::Broadcast && conv.id.peer == 0) + { + filtered.push_back(conv); + } + } + + if (filtered.size() != items_.size()) + { + return false; + } + + for (size_t index = 0; index < filtered.size(); ++index) + { + if (!(items_[index].conv == filtered[index].id)) + { + return false; + } + } + + for (size_t index = 0; index < filtered.size(); ++index) + { + updateListItem(index, filtered[index]); + } + return true; +} + +void ChatMessageListScreen::updateListItem(const size_t index, + const chat::ConversationMeta& conv) +{ + if (index >= items_.size()) + { + return; + } + + MessageItem& item = items_[index]; + std::string title = "[" + std::string(protocol_short_label(conv.id.protocol)) + "] " + conv.name; + lv_label_set_text(item.name_label, title.c_str()); + ::ui::fonts::apply_chat_content_font(item.name_label, title.c_str()); + + std::string preview = truncate_preview(conv.preview); + lv_label_set_text(item.preview_label, preview.c_str()); + ::ui::fonts::apply_chat_content_font(item.preview_label, preview.c_str()); + + char time_buf[16]; + format_time_hhmm(time_buf, conv.last_timestamp); + lv_label_set_text(item.time_label, time_buf); + ::ui::fonts::apply_ui_chrome_font(item.time_label); + + if (conv.unread > 0) + { + char unread_str[16]; + snprintf(unread_str, sizeof(unread_str), "%d", conv.unread); + lv_label_set_text(item.unread_label, unread_str); + } + else + { + lv_label_set_text(item.unread_label, ""); + } + ::ui::fonts::apply_ui_chrome_font(item.unread_label); + item.unread_count = conv.unread; +} + void ChatMessageListScreen::item_event_cb(lv_event_t* e) { auto* screen = 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 36ac0407..309522cb 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 @@ -14,12 +14,15 @@ #include "sys/event_bus.h" #include "team/protocol/team_location_marker.h" #include "team/usecase/team_controller.h" +#include "ui/assets/fonts/fonts.h" #include "ui/page/page_profile.h" #include "ui/screens/team/team_ui_store.h" #include "ui/ui_common.h" +#include "ui/widgets/ime/ime_widget.h" #include "ui/widgets/system_notification.h" #include #include +#include #include #ifndef CHAT_UI_LOG_ENABLE @@ -134,6 +137,18 @@ std::string truncate_text(const std::string& text, size_t max_len) return text.substr(0, max_len - 3) + "..."; } +std::string resolve_contact_name(chat::NodeId node_id) +{ + std::string name = app::messagingFacade().getContactService().getContactName(node_id); + if (!name.empty()) + { + return name; + } + char fallback[16] = {}; + std::snprintf(fallback, sizeof(fallback), "%08lX", static_cast(node_id)); + return fallback; +} + std::string format_team_chat_entry(const team::ui::TeamChatLogEntry& entry) { if (entry.type == team::proto::TeamChatType::Text) @@ -292,6 +307,7 @@ UiController::UiController(lv_obj_t* parent, chat::ChatService& service, chat::C UiController::~UiController() { closeTeamPositionPicker(false); + closeKeyVerificationModal(false); stopTeamConversationTimer(); service_.setModelEnabled(false); channel_list_.reset(); @@ -318,12 +334,10 @@ void UiController::update() { // Process incoming messages service_.processIncoming(); + service_.flushStore(); - // Refresh UI if needed - if (state_ == State::ChannelList && channel_list_) - { - refreshUnreadCounts(); - } + // Refresh UI only when an event marks the conversation list dirty. + refreshUnreadCounts(false); } void UiController::onChannelClicked(chat::ConversationId conv) @@ -401,19 +415,26 @@ void UiController::onChatEvent(sys::Event* event) // Note: Haptic feedback is now handled by the app runtime event pump // No need to call vibrator() here - if (state_ == State::Conversation && - (uint8_t)current_channel_ == msg_event->channel) + const ChatMessage* latest = service_.getMessage(msg_event->msg_id); + if (latest) { - CHAT_UI_LOG("[UiController::onChatEvent] Updating conversation UI...\n"); - auto messages = service_.getRecentMessages(current_conv_, 50); - conversation_->clearMessages(); - for (const auto& m : messages) + const bool is_current_conversation = + (state_ == State::Conversation) && (current_conv_ == chat::ConversationId(latest->channel, + latest->peer, + latest->protocol)); + updateConversationMetaForMessage(*latest, !is_current_conversation); + if (is_current_conversation) { - conversation_->addMessage(m); + (void)updateConversationViewForIncoming(*latest); + reloadConversationView(); + service_.markConversationRead(current_conv_); + } + else + { + conversation_list_dirty_ = true; } - conversation_->scrollToBottom(); } - refreshUnreadCounts(); + refreshUnreadCounts(false); break; } @@ -422,13 +443,11 @@ void UiController::onChatEvent(sys::Event* event) sys::ChatSendResultEvent* result_event = (sys::ChatSendResultEvent*)event; if (state_ == State::Conversation && conversation_) { - auto messages = service_.getRecentMessages(current_conv_, 50); - conversation_->clearMessages(); - for (const auto& m : messages) + const ChatMessage* msg = service_.getMessage(result_event->msg_id); + if (!msg || !conversation_->updateMessageStatus(result_event->msg_id, msg->status)) { - conversation_->addMessage(m); + reloadConversationView(); } - conversation_->scrollToBottom(); } (void)result_event; break; @@ -436,9 +455,16 @@ void UiController::onChatEvent(sys::Event* event) case sys::EventType::ChatUnreadChanged: { - refreshUnreadCounts(); + conversation_list_dirty_ = true; + refreshUnreadCounts(false); break; } + case sys::EventType::KeyVerificationNumberRequest: + break; + case sys::EventType::KeyVerificationNumberInform: + break; + case sys::EventType::KeyVerificationFinal: + break; default: break; @@ -483,7 +509,7 @@ void UiController::switchToChannelList() } service_.setModelEnabled(true); - refreshUnreadCounts(); + refreshUnreadCounts(true); } void UiController::switchToConversation(chat::ConversationId conv) @@ -731,28 +757,29 @@ void UiController::handleSendMessage(const std::string& text) } void UiController::refreshUnreadCounts() +{ + refreshUnreadCounts(true); +} + +void UiController::refreshUnreadCounts(const bool force_reload) { if (!channel_list_) { return; } - size_t total = 0; - auto convs = service_.getConversations(0, 0, &total); - - // Update conversation names with contact nicknames - for (auto& conv : convs) + if (force_reload || conversation_list_dirty_ || cached_conversations_.empty()) { - if (conv.id.peer != 0) - { - std::string contact_name = app::messagingFacade().getContactService().getContactName(conv.id.peer); - if (!contact_name.empty()) - { - conv.name = contact_name; - } - // Otherwise keep the short_name from ConversationMeta - } + syncConversationListFromStore(); } + applyConversationListToUi(); +} + +void UiController::syncConversationListFromStore() +{ + size_t total = 0; + cached_conversations_ = service_.getConversations(0, 0, &total); + normalizeConversationNames(cached_conversations_); team::ui::TeamUiSnapshot team_snap; if (team::ui::team_ui_get_store().load(team_snap) && team_snap.has_team_id) @@ -775,16 +802,113 @@ void UiController::refreshUnreadCounts() { team_conv.preview = "No messages"; } - convs.insert(convs.begin(), team_conv); + cached_conversations_.insert(cached_conversations_.begin(), team_conv); } - channel_list_->setConversations(convs); - channel_list_->setSelectedConversation(current_conv_); + conversation_list_dirty_ = false; +} - // Update header status (battery only, with icon) +void UiController::normalizeConversationNames(std::vector& convs) const +{ + for (auto& conv : convs) + { + if (conv.id.peer == 0) + { + continue; + } + std::string contact_name = app::messagingFacade().getContactService().getContactName(conv.id.peer); + if (!contact_name.empty()) + { + conv.name = contact_name; + } + } +} + +void UiController::applyConversationListToUi() +{ + if (!channel_list_) + { + return; + } + + channel_list_->setConversations(cached_conversations_); + channel_list_->setSelectedConversation(current_conv_); channel_list_->updateBatteryFromBoard(); } +void UiController::updateConversationMetaForMessage(const chat::ChatMessage& msg, + const bool increment_unread) +{ + if (isTeamConversation(chat::ConversationId(msg.channel, msg.peer, msg.protocol))) + { + conversation_list_dirty_ = true; + return; + } + + chat::ConversationMeta meta; + meta.id = chat::ConversationId(msg.channel, msg.peer, msg.protocol); + meta.name = (msg.peer == 0) ? "Broadcast" : resolve_contact_name(msg.peer); + meta.preview = msg.text; + meta.last_timestamp = msg.timestamp; + meta.unread = (increment_unread && msg.status == chat::MessageStatus::Incoming) ? 1 : 0; + + bool found = false; + for (auto it = cached_conversations_.begin(); it != cached_conversations_.end(); ++it) + { + if (!(it->id == meta.id)) + { + continue; + } + found = true; + meta.unread += it->unread; + if (!increment_unread && msg.status == chat::MessageStatus::Incoming) + { + meta.unread = 0; + } + cached_conversations_.erase(it); + break; + } + + if (!found && msg.peer == 0) + { + meta.name = "Broadcast"; + } + + cached_conversations_.insert(cached_conversations_.begin(), meta); +} + +bool UiController::updateConversationViewForIncoming(const chat::ChatMessage& msg) +{ + if (!conversation_) + { + return false; + } + + if (!(current_conv_ == chat::ConversationId(msg.channel, msg.peer, msg.protocol))) + { + return false; + } + + conversation_->addMessage(msg); + return true; +} + +void UiController::reloadConversationView() +{ + if (!conversation_ || team_conv_active_) + { + return; + } + + auto messages = service_.getRecentMessages(current_conv_, 50); + conversation_->clearMessages(); + for (const auto& msg : messages) + { + conversation_->addMessage(msg); + } + conversation_->scrollToBottom(); +} + bool UiController::isTeamConversation(const chat::ConversationId& conv) const { return isTeamConversationId(conv); @@ -1129,6 +1253,432 @@ void UiController::closeTeamPositionPicker(bool restore_group) team_position_prev_group_ = nullptr; } +bool UiController::isKeyVerificationModalOpen() const +{ + return key_verify_overlay_ != nullptr; +} + +void UiController::clearKeyVerificationError() +{ + if (key_verify_error_label_) + { + lv_label_set_text(key_verify_error_label_, ""); + } +} + +void UiController::key_verify_submit_event_cb(lv_event_t* e) +{ + auto* controller = static_cast(lv_event_get_user_data(e)); + if (!controller) + { + return; + } + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_KEY) + { + lv_key_t key = static_cast(lv_event_get_key(e)); + if (key != LV_KEY_ENTER) + { + return; + } + } + if (code == LV_EVENT_CLICKED || code == LV_EVENT_KEY) + { + controller->submitKeyVerificationNumber(); + } +} + +void UiController::key_verify_close_event_cb(lv_event_t* e) +{ + auto* controller = static_cast(lv_event_get_user_data(e)); + if (!controller) + { + return; + } + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_KEY) + { + lv_key_t key = static_cast(lv_event_get_key(e)); + if (key != LV_KEY_ENTER) + { + return; + } + } + if (code == LV_EVENT_CLICKED || code == LV_EVENT_KEY) + { + controller->closeKeyVerificationModal(true); + } +} + +void UiController::key_verify_trust_event_cb(lv_event_t* e) +{ + auto* controller = static_cast(lv_event_get_user_data(e)); + if (!controller) + { + return; + } + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_KEY) + { + lv_key_t key = static_cast(lv_event_get_key(e)); + if (key != LV_KEY_ENTER) + { + return; + } + } + if (code == LV_EVENT_CLICKED || code == LV_EVENT_KEY) + { + controller->trustKeyFromVerificationModal(); + } +} + +void UiController::closeKeyVerificationModal(bool restore_group) +{ + if (key_verify_ime_) + { + key_verify_ime_->detach(); + key_verify_ime_.reset(); + } + + if (key_verify_group_ && lv_group_get_default() == key_verify_group_) + { + if (restore_group) + { + lv_group_t* restore_target = key_verify_prev_group_ ? key_verify_prev_group_ : app_g; + set_default_group(restore_target); + } + else + { + set_default_group(nullptr); + } + } + + if (key_verify_overlay_ && lv_obj_is_valid(key_verify_overlay_)) + { + lv_obj_del(key_verify_overlay_); + } + if (key_verify_group_) + { + lv_group_del(key_verify_group_); + } + + key_verify_overlay_ = nullptr; + key_verify_panel_ = nullptr; + key_verify_desc_ = nullptr; + key_verify_textarea_ = nullptr; + key_verify_error_label_ = nullptr; + key_verify_group_ = nullptr; + key_verify_prev_group_ = nullptr; + key_verify_node_id_ = 0; + key_verify_nonce_ = 0; + key_verify_expects_number_ = false; + key_verify_can_trust_ = false; +} + +void UiController::openKeyVerificationNumberModal(chat::NodeId node_id, uint64_t nonce) +{ + closeKeyVerificationModal(false); + if (!parent_) + { + return; + } + + key_verify_node_id_ = node_id; + key_verify_nonce_ = nonce; + key_verify_expects_number_ = true; + key_verify_can_trust_ = false; + + key_verify_prev_group_ = lv_group_get_default(); + key_verify_group_ = lv_group_create(); + set_default_group(key_verify_group_); + + key_verify_overlay_ = lv_obj_create(parent_); + lv_obj_set_size(key_verify_overlay_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(key_verify_overlay_, lv_color_black(), 0); + lv_obj_set_style_bg_opa(key_verify_overlay_, LV_OPA_50, 0); + lv_obj_set_style_border_width(key_verify_overlay_, 0, 0); + lv_obj_set_style_pad_all(key_verify_overlay_, 0, 0); + lv_obj_set_style_radius(key_verify_overlay_, 0, 0); + lv_obj_clear_flag(key_verify_overlay_, LV_OBJ_FLAG_SCROLLABLE); + + const auto& profile = ::ui::page_profile::current(); + const auto modal_size = ::ui::page_profile::resolve_modal_size( + profile.large_touch_hitbox ? 560 : 320, + profile.large_touch_hitbox ? 380 : 220, + key_verify_overlay_); + + key_verify_panel_ = lv_obj_create(key_verify_overlay_); + lv_obj_set_size(key_verify_panel_, modal_size.width, modal_size.height); + lv_obj_center(key_verify_panel_); + lv_obj_set_style_bg_color(key_verify_panel_, lv_color_hex(0xFAF0D8), 0); + lv_obj_set_style_bg_opa(key_verify_panel_, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(key_verify_panel_, 1, 0); + lv_obj_set_style_border_color(key_verify_panel_, lv_color_hex(0xE7C98F), 0); + lv_obj_set_style_radius(key_verify_panel_, 10, 0); + lv_obj_set_style_pad_all(key_verify_panel_, ::ui::page_profile::resolve_modal_pad(), 0); + lv_obj_clear_flag(key_verify_panel_, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* title = lv_label_create(key_verify_panel_); + lv_label_set_text(title, "Key Verification"); + lv_obj_set_style_text_color(title, lv_color_hex(0x6B4A1E), 0); + lv_obj_set_style_text_font(title, &lv_font_noto_cjk_16_2bpp, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + std::string desc = "Enter number for "; + desc += resolve_contact_name(node_id); + key_verify_desc_ = lv_label_create(key_verify_panel_); + lv_label_set_text(key_verify_desc_, desc.c_str()); + lv_obj_set_width(key_verify_desc_, LV_PCT(100)); + lv_obj_set_style_text_align(key_verify_desc_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(key_verify_desc_, lv_color_hex(0x8A6A3A), 0); + lv_obj_align(key_verify_desc_, LV_ALIGN_TOP_MID, 0, 34); + + key_verify_textarea_ = lv_textarea_create(key_verify_panel_); + lv_obj_set_width(key_verify_textarea_, LV_PCT(100)); + lv_textarea_set_one_line(key_verify_textarea_, true); + lv_textarea_set_placeholder_text(key_verify_textarea_, "6 digits"); + lv_textarea_set_accepted_chars(key_verify_textarea_, "0123456789"); + lv_textarea_set_max_length(key_verify_textarea_, 6); + lv_obj_align(key_verify_textarea_, LV_ALIGN_TOP_MID, 0, 72); + + key_verify_error_label_ = lv_label_create(key_verify_panel_); + lv_label_set_text(key_verify_error_label_, ""); + lv_obj_set_width(key_verify_error_label_, LV_PCT(100)); + lv_obj_set_style_text_align(key_verify_error_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(key_verify_error_label_, lv_color_hex(0xB94A2C), 0); + lv_obj_align(key_verify_error_label_, LV_ALIGN_TOP_MID, 0, 110); + + lv_obj_t* submit_btn = lv_btn_create(key_verify_panel_); + lv_obj_set_size(submit_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); + lv_obj_align(submit_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_t* submit_label = lv_label_create(submit_btn); + lv_label_set_text(submit_label, "Submit"); + lv_obj_center(submit_label); + + lv_obj_t* cancel_btn = lv_btn_create(key_verify_panel_); + lv_obj_set_size(cancel_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); + lv_obj_align(cancel_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + lv_obj_t* cancel_label = lv_label_create(cancel_btn); + lv_label_set_text(cancel_label, "Cancel"); + lv_obj_center(cancel_label); + + lv_obj_add_event_cb(submit_btn, key_verify_submit_event_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(submit_btn, key_verify_submit_event_cb, LV_EVENT_KEY, this); + lv_obj_add_event_cb(cancel_btn, key_verify_close_event_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(cancel_btn, key_verify_close_event_cb, LV_EVENT_KEY, this); + + lv_group_add_obj(key_verify_group_, key_verify_textarea_); + lv_group_add_obj(key_verify_group_, submit_btn); + lv_group_add_obj(key_verify_group_, cancel_btn); + lv_group_focus_obj(key_verify_textarea_); + + if (::ui::page_profile::current().large_touch_hitbox) + { + key_verify_ime_.reset(new ::ui::widgets::ImeWidget()); + key_verify_ime_->init(key_verify_panel_, key_verify_textarea_); + key_verify_ime_->setMode(::ui::widgets::ImeWidget::Mode::NUM); + } + + lv_obj_move_foreground(key_verify_overlay_); +} + +void UiController::openKeyVerificationInfoModal(chat::NodeId node_id, uint32_t number) +{ + closeKeyVerificationModal(false); + if (!parent_) + { + return; + } + + key_verify_node_id_ = node_id; + key_verify_expects_number_ = false; + key_verify_can_trust_ = false; + + key_verify_prev_group_ = lv_group_get_default(); + key_verify_group_ = lv_group_create(); + set_default_group(key_verify_group_); + + key_verify_overlay_ = lv_obj_create(parent_); + lv_obj_set_size(key_verify_overlay_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(key_verify_overlay_, lv_color_black(), 0); + lv_obj_set_style_bg_opa(key_verify_overlay_, LV_OPA_50, 0); + lv_obj_set_style_border_width(key_verify_overlay_, 0, 0); + lv_obj_set_style_pad_all(key_verify_overlay_, 0, 0); + lv_obj_set_style_radius(key_verify_overlay_, 0, 0); + lv_obj_clear_flag(key_verify_overlay_, LV_OBJ_FLAG_SCROLLABLE); + + const auto modal_size = ::ui::page_profile::resolve_modal_size(360, 220, key_verify_overlay_); + key_verify_panel_ = lv_obj_create(key_verify_overlay_); + lv_obj_set_size(key_verify_panel_, modal_size.width, modal_size.height); + lv_obj_center(key_verify_panel_); + + lv_obj_t* title = lv_label_create(key_verify_panel_); + lv_label_set_text(title, "Verification Number"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + char num_buf[24] = {}; + std::snprintf(num_buf, + sizeof(num_buf), + "%03u %03u", + static_cast(number / 1000U), + static_cast(number % 1000U)); + key_verify_desc_ = lv_label_create(key_verify_panel_); + std::string desc = resolve_contact_name(node_id) + "\nShare this number:\n"; + desc += num_buf; + lv_label_set_text(key_verify_desc_, desc.c_str()); + lv_obj_set_width(key_verify_desc_, LV_PCT(100)); + lv_obj_set_style_text_align(key_verify_desc_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(key_verify_desc_, LV_ALIGN_CENTER, 0, -12); + + lv_obj_t* close_btn = lv_btn_create(key_verify_panel_); + lv_obj_set_size(close_btn, LV_PCT(100), ::ui::page_profile::resolve_control_button_height()); + lv_obj_align(close_btn, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_t* close_label = lv_label_create(close_btn); + lv_label_set_text(close_label, "OK"); + lv_obj_center(close_label); + lv_obj_add_event_cb(close_btn, key_verify_close_event_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(close_btn, key_verify_close_event_cb, LV_EVENT_KEY, this); + lv_group_add_obj(key_verify_group_, close_btn); + lv_group_focus_obj(close_btn); + lv_obj_move_foreground(key_verify_overlay_); +} + +void UiController::openKeyVerificationFinalModal(chat::NodeId node_id, const char* code, bool is_sender) +{ + closeKeyVerificationModal(false); + if (!parent_) + { + return; + } + + key_verify_node_id_ = node_id; + key_verify_expects_number_ = false; + key_verify_can_trust_ = true; + + key_verify_prev_group_ = lv_group_get_default(); + key_verify_group_ = lv_group_create(); + set_default_group(key_verify_group_); + + key_verify_overlay_ = lv_obj_create(parent_); + lv_obj_set_size(key_verify_overlay_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(key_verify_overlay_, lv_color_black(), 0); + lv_obj_set_style_bg_opa(key_verify_overlay_, LV_OPA_50, 0); + lv_obj_set_style_border_width(key_verify_overlay_, 0, 0); + lv_obj_set_style_pad_all(key_verify_overlay_, 0, 0); + lv_obj_set_style_radius(key_verify_overlay_, 0, 0); + lv_obj_clear_flag(key_verify_overlay_, LV_OBJ_FLAG_SCROLLABLE); + + const auto modal_size = ::ui::page_profile::resolve_modal_size(420, 260, key_verify_overlay_); + key_verify_panel_ = lv_obj_create(key_verify_overlay_); + lv_obj_set_size(key_verify_panel_, modal_size.width, modal_size.height); + lv_obj_center(key_verify_panel_); + + lv_obj_t* title = lv_label_create(key_verify_panel_); + lv_label_set_text(title, "Compare Verification Code"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + std::string desc = resolve_contact_name(node_id); + desc += "\n"; + desc += is_sender ? "Send this code and compare:\n" : "Confirm received code:\n"; + desc += (code && code[0] != '\0') ? code : "--------"; + desc += "\n\nIf it matches, trust the key."; + key_verify_desc_ = lv_label_create(key_verify_panel_); + lv_label_set_text(key_verify_desc_, desc.c_str()); + lv_obj_set_width(key_verify_desc_, LV_PCT(100)); + lv_obj_set_style_text_align(key_verify_desc_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(key_verify_desc_, LV_ALIGN_CENTER, 0, -8); + + lv_obj_t* trust_btn = lv_btn_create(key_verify_panel_); + lv_obj_set_size(trust_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); + lv_obj_align(trust_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_t* trust_label = lv_label_create(trust_btn); + lv_label_set_text(trust_label, "Trust Key"); + lv_obj_center(trust_label); + + lv_obj_t* close_btn = lv_btn_create(key_verify_panel_); + lv_obj_set_size(close_btn, LV_PCT(48), ::ui::page_profile::resolve_control_button_height()); + lv_obj_align(close_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + lv_obj_t* close_label = lv_label_create(close_btn); + lv_label_set_text(close_label, "Later"); + lv_obj_center(close_label); + + lv_obj_add_event_cb(trust_btn, key_verify_trust_event_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(trust_btn, key_verify_trust_event_cb, LV_EVENT_KEY, this); + lv_obj_add_event_cb(close_btn, key_verify_close_event_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(close_btn, key_verify_close_event_cb, LV_EVENT_KEY, this); + lv_group_add_obj(key_verify_group_, trust_btn); + lv_group_add_obj(key_verify_group_, close_btn); + lv_group_focus_obj(trust_btn); + lv_obj_move_foreground(key_verify_overlay_); +} + +void UiController::submitKeyVerificationNumber() +{ + if (!key_verify_expects_number_ || !key_verify_textarea_) + { + return; + } + + clearKeyVerificationError(); + const char* text = lv_textarea_get_text(key_verify_textarea_); + if (!text || text[0] == '\0') + { + if (key_verify_error_label_) + { + lv_label_set_text(key_verify_error_label_, "Enter the 6-digit number"); + } + return; + } + + char* end_ptr = nullptr; + unsigned long parsed = std::strtoul(text, &end_ptr, 10); + if (!end_ptr || *end_ptr != '\0' || parsed > 999999UL) + { + if (key_verify_error_label_) + { + lv_label_set_text(key_verify_error_label_, "Invalid number"); + } + return; + } + + chat::IMeshAdapter* mesh = app::messagingFacade().getMeshAdapter(); + if (!mesh) + { + if (key_verify_error_label_) + { + lv_label_set_text(key_verify_error_label_, "Mesh unavailable"); + } + return; + } + + const bool ok = mesh->submitKeyVerificationNumber(key_verify_node_id_, key_verify_nonce_, + static_cast(parsed)); + if (!ok) + { + if (key_verify_error_label_) + { + lv_label_set_text(key_verify_error_label_, "Submit failed"); + } + return; + } + + ::ui::SystemNotification::show("Verification number sent", 2000); + closeKeyVerificationModal(true); +} + +void UiController::trustKeyFromVerificationModal() +{ + if (!key_verify_can_trust_ || key_verify_node_id_ == 0) + { + closeKeyVerificationModal(true); + return; + } + + bool ok = app::messagingFacade().getContactService().setNodeKeyManuallyVerified(key_verify_node_id_, true); + ::ui::SystemNotification::show(ok ? "Key marked trusted" : "Key trust failed", 2000); + closeKeyVerificationModal(true); +} + void UiController::onTeamPositionCancel() { closeTeamPositionPicker(true); 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 8be81aee..3647dd51 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 @@ -157,6 +157,27 @@ void ChatConversationScreen::scrollToBottom() lv_obj_scroll_to_view(messages_.back().container, LV_ANIM_OFF); } +bool ChatConversationScreen::updateMessageStatus(const chat::MessageId msg_id, + const chat::MessageStatus status) +{ + if (!guard_ || !guard_->alive || msg_id == 0) + { + return false; + } + + for (auto& item : messages_) + { + if (item.msg.msg_id != msg_id) + { + continue; + } + item.msg.status = status; + return true; + } + + return false; +} + void ChatConversationScreen::setActionCallback(void (*cb)(ActionIntent intent, void*), void* user_data) { action_cb_ = cb; 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 2f00c42f..f55a301b 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 @@ -7,6 +7,7 @@ #include "app/app_config.h" #include "app/app_facade_access.h" #include "chat/infra/mesh_protocol_utils.h" +#include "chat/ports/i_mesh_adapter.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "platform/ui/gps_runtime.h" @@ -152,6 +153,7 @@ static void refresh_filter_checked_state() { if (g_contacts_state.contacts_btn == nullptr || g_contacts_state.nearby_btn == nullptr || + g_contacts_state.ignored_btn == nullptr || g_contacts_state.broadcast_btn == nullptr) { return; @@ -159,6 +161,7 @@ static void refresh_filter_checked_state() lv_obj_clear_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); + lv_obj_clear_state(g_contacts_state.ignored_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); if (g_contacts_state.team_btn) { @@ -177,6 +180,10 @@ static void refresh_filter_checked_state() { lv_obj_add_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); } + else if (g_contacts_state.current_mode == ContactsMode::Ignored) + { + lv_obj_add_state(g_contacts_state.ignored_btn, LV_STATE_CHECKED); + } else if (g_contacts_state.current_mode == ContactsMode::Broadcast) { lv_obj_add_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); @@ -618,6 +625,11 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_add_event_cb(g_contacts_state.nearby_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(g_contacts_state.nearby_btn, on_filter_clicked, LV_EVENT_CLICKED, nullptr); } + if (g_contacts_state.ignored_btn) + { + lv_obj_add_event_cb(g_contacts_state.ignored_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); + lv_obj_add_event_cb(g_contacts_state.ignored_btn, on_filter_clicked, LV_EVENT_CLICKED, nullptr); + } if (g_contacts_state.broadcast_btn) { lv_obj_add_event_cb(g_contacts_state.broadcast_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); @@ -647,6 +659,7 @@ static void on_filter_focused(lv_event_t* e) ContactsMode new_mode = g_contacts_state.current_mode; if (tgt == g_contacts_state.contacts_btn) new_mode = ContactsMode::Contacts; else if (tgt == g_contacts_state.nearby_btn) new_mode = ContactsMode::Nearby; + else if (tgt == g_contacts_state.ignored_btn) new_mode = ContactsMode::Ignored; else if (tgt == g_contacts_state.broadcast_btn) new_mode = ContactsMode::Broadcast; else if (tgt == g_contacts_state.team_btn) new_mode = ContactsMode::Team; else if (tgt == g_contacts_state.discover_btn) new_mode = ContactsMode::Discover; @@ -683,6 +696,15 @@ static void on_filter_clicked(lv_event_t* e) refresh_contacts_data(); refresh_ui(); } + else if (tgt == g_contacts_state.ignored_btn && + g_contacts_state.current_mode != ContactsMode::Ignored) + { + g_contacts_state.current_mode = ContactsMode::Ignored; + g_contacts_state.current_page = 0; + g_contacts_state.selected_index = -1; + refresh_contacts_data(); + refresh_ui(); + } // Press on filter mode button: move focus to List column contacts_focus_to_list(); @@ -781,7 +803,9 @@ static const chat::contacts::NodeInfo* get_selected_node() } const auto& list = (g_contacts_state.current_mode == ContactsMode::Contacts) ? g_contacts_state.contacts_list - : g_contacts_state.nearby_list; + : (g_contacts_state.current_mode == ContactsMode::Nearby) + ? g_contacts_state.nearby_list + : g_contacts_state.ignored_list; if (g_contacts_state.selected_index >= static_cast(list.size())) { return nullptr; @@ -805,6 +829,13 @@ static const chat::contacts::NodeInfo* find_node_by_id(uint32_t node_id) return &node; } } + for (const auto& node : g_contacts_state.ignored_list) + { + if (node.node_id == node_id) + { + return &node; + } + } return nullptr; } @@ -1988,9 +2019,42 @@ enum class ActionMenuCommand : uint8_t Edit = 4, Add = 5, Delete = 6, - Cancel = 7, + ToggleIgnore = 7, + Cancel = 8, }; +static void toggle_selected_node_ignore() +{ + const auto* node = get_selected_node(); + if (!node || !g_contacts_state.contact_service) + { + ::ui::SystemNotification::show("Ignore unavailable", 1800); + contacts_focus_to_list(); + return; + } + + const bool ignored = !node->is_ignored; + const bool node_is_contact = node->is_contact; + if (!g_contacts_state.contact_service->setNodeIgnored(node->node_id, ignored)) + { + ::ui::SystemNotification::show("Ignore update failed", 1800); + contacts_focus_to_list(); + return; + } + + refresh_contacts_data(); + refresh_ui(); + if (ignored && g_contacts_state.current_mode == ContactsMode::Nearby && !node_is_contact) + { + ::ui::SystemNotification::show("Node ignored and hidden", 2000); + } + else + { + ::ui::SystemNotification::show(ignored ? "Node ignored" : "Node unignored", 1800); + } + contacts_focus_to_list(); +} + static lv_obj_t* create_action_menu_button(lv_obj_t* parent, const char* text) { lv_obj_t* btn = lv_btn_create(parent); @@ -2055,6 +2119,9 @@ static void on_action_menu_item_clicked(lv_event_t* e) case ActionMenuCommand::Delete: open_delete_confirm_modal(); break; + case ActionMenuCommand::ToggleIgnore: + toggle_selected_node_ignore(); + break; case ActionMenuCommand::Cancel: default: contacts_focus_to_list(); @@ -2077,12 +2144,18 @@ static void open_action_menu_modal() return; } + const chat::contacts::NodeInfo* node = get_selected_node(); + const bool show_ignore = (node != nullptr) && + (g_contacts_state.current_mode == ContactsMode::Contacts || + g_contacts_state.current_mode == ContactsMode::Nearby); + int action_count = 2; // Chat + Cancel if (g_contacts_state.current_mode == ContactsMode::Contacts) { action_count += 3; // Edit/Delete/Info } - else if (g_contacts_state.current_mode == ContactsMode::Nearby) + else if (g_contacts_state.current_mode == ContactsMode::Nearby || + g_contacts_state.current_mode == ContactsMode::Ignored) { action_count += 2; // Add/Info } @@ -2090,6 +2163,10 @@ static void open_action_menu_modal() { action_count += 1; // Position } + if (show_ignore) + { + action_count += 1; + } int modal_h = (::ui::page_profile::current().large_touch_hitbox ? 84 : 62) + action_count * (page_button_height() + (::ui::page_profile::current().large_touch_hitbox ? 8 : 2)); if (modal_h > 216) @@ -2179,7 +2256,8 @@ static void open_action_menu_modal() add_action(ActionMenuCommand::Delete, "Delete"); add_action(ActionMenuCommand::Info, "Info"); } - else if (g_contacts_state.current_mode == ContactsMode::Nearby) + else if (g_contacts_state.current_mode == ContactsMode::Nearby || + g_contacts_state.current_mode == ContactsMode::Ignored) { add_action(ActionMenuCommand::Add, "Add"); add_action(ActionMenuCommand::Info, "Info"); @@ -2188,6 +2266,11 @@ static void open_action_menu_modal() { add_action(ActionMenuCommand::Position, "Position"); } + if (show_ignore) + { + add_action(ActionMenuCommand::ToggleIgnore, + (node && node->is_ignored) ? "Unignore" : "Ignore"); + } add_action(ActionMenuCommand::Cancel, "Cancel"); if (first_focus) @@ -2286,6 +2369,10 @@ void refresh_ui() node.snr); } } + else if (g_contacts_state.current_mode == ContactsMode::Ignored) + { + CONTACTS_LOG("[Contacts] Ignored mode: %zu nodes\n", g_contacts_state.ignored_list.size()); + } // Ensure list containers exist (structure handled in layout) contacts::ui::layout::ensure_list_subcontainers(); @@ -2313,6 +2400,10 @@ void refresh_ui() { current_list = &g_contacts_state.nearby_list; } + else if (g_contacts_state.current_mode == ContactsMode::Ignored) + { + current_list = &g_contacts_state.ignored_list; + } else if (g_contacts_state.current_mode == ContactsMode::Team) { chat::contacts::NodeInfo team_node{}; @@ -2365,6 +2456,7 @@ void refresh_ui() const bool use_scroll_list = (g_contacts_state.current_mode == ContactsMode::Contacts) || (g_contacts_state.current_mode == ContactsMode::Nearby) || + (g_contacts_state.current_mode == ContactsMode::Ignored) || (g_contacts_state.current_mode == ContactsMode::Broadcast) || (g_contacts_state.current_mode == ContactsMode::Discover); const bool append_back_item = use_scroll_list; @@ -2424,6 +2516,22 @@ void refresh_ui() status_text += proto; } } + else if (g_contacts_state.current_mode == ContactsMode::Ignored) + { + status_text = "Ignored"; + const std::string seen = format_time_status(node.last_seen); + if (!seen.empty()) + { + status_text += " / "; + status_text += seen; + } + const char* proto = node_protocol_short_label(node.protocol); + if (proto[0] != '\0') + { + status_text += " "; + status_text += proto; + } + } else if (g_contacts_state.current_mode == ContactsMode::Team) { status_text = "Team"; diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp index e38d03c0..cf612952 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp @@ -24,12 +24,14 @@ static int mode_to_index(contacts::ui::ContactsMode mode) return 0; case contacts::ui::ContactsMode::Nearby: return 1; - case contacts::ui::ContactsMode::Broadcast: + case contacts::ui::ContactsMode::Ignored: return 2; - case contacts::ui::ContactsMode::Team: + case contacts::ui::ContactsMode::Broadcast: return 3; - case contacts::ui::ContactsMode::Discover: + case contacts::ui::ContactsMode::Team: return 4; + case contacts::ui::ContactsMode::Discover: + return 5; } return 0; } @@ -48,7 +50,7 @@ static lv_obj_t* get_top_back_button(void* /*ctx*/) static size_t get_filter_count(void* /*ctx*/) { - return 5; + return 6; } static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) @@ -61,10 +63,12 @@ static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) case 1: return g_contacts_state.nearby_btn; case 2: - return g_contacts_state.broadcast_btn; + return g_contacts_state.ignored_btn; case 3: - return g_contacts_state.team_btn; + return g_contacts_state.broadcast_btn; case 4: + return g_contacts_state.team_btn; + case 5: return g_contacts_state.discover_btn; default: return nullptr; diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp index 140abc89..94a38bb9 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp @@ -126,6 +126,15 @@ void create_filter_panel(lv_obj_t* parent) style::apply_label_primary(nearby_label); lv_obj_center(nearby_label); + g_contacts_state.ignored_btn = lv_btn_create(g_contacts_state.filter_panel); + lv_obj_set_size(g_contacts_state.ignored_btn, LV_PCT(100), profile.filter_button_height); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.ignored_btn); + style::apply_btn_filter(g_contacts_state.ignored_btn); + lv_obj_t* ignored_label = lv_label_create(g_contacts_state.ignored_btn); + lv_label_set_text(ignored_label, "Ignored"); + style::apply_label_primary(ignored_label); + lv_obj_center(ignored_label); + g_contacts_state.broadcast_btn = lv_btn_create(g_contacts_state.filter_panel); lv_obj_set_size(g_contacts_state.broadcast_btn, LV_PCT(100), profile.filter_button_height); ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.broadcast_btn); diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp index c7f3708e..87e40ca9 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp @@ -77,10 +77,12 @@ void refresh_contacts_data_impl_internal() chat::contacts::ContactService& contact_service = app::messagingFacade().getContactService(); g_contacts_state.contacts_list = contact_service.getContacts(); g_contacts_state.nearby_list = contact_service.getNearby(); + g_contacts_state.ignored_list = contact_service.getIgnoredNodes(); - CONTACTS_LOG("[Contacts] Data refreshed: %zu contacts, %zu nearby\n", + CONTACTS_LOG("[Contacts] Data refreshed: %zu contacts, %zu nearby, %zu ignored\n", g_contacts_state.contacts_list.size(), - g_contacts_state.nearby_list.size()); + g_contacts_state.nearby_list.size(), + g_contacts_state.ignored_list.size()); } } // namespace diff --git a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp index cb6d6427..15218330 100644 --- a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp @@ -801,7 +801,20 @@ void set_node_info(const chat::contacts::NodeInfo& node) update_location_map(node); - set_label_text(s_widgets.link_title_label, "Link"); + char link_title[48]; + if (node.protocol == chat::contacts::NodeProtocolType::Meshtastic) + { + snprintf(link_title, sizeof(link_title), "Link / Meshtastic"); + } + else if (node.protocol == chat::contacts::NodeProtocolType::MeshCore) + { + snprintf(link_title, sizeof(link_title), "Link / MeshCore"); + } + else + { + snprintf(link_title, sizeof(link_title), "Link"); + } + set_label_text(s_widgets.link_title_label, link_title); // Link info (best-effort) char rssi_buf[24]; @@ -832,19 +845,85 @@ void set_node_info(const chat::contacts::NodeInfo& node) set_label_text(s_widgets.link_sf_label, sf_buf); set_label_text(s_widgets.link_bw_label, bw_buf); - char last_heard_buf[32]; + char last_heard_buf[64]; format_age("Last heard:", node.last_seen, last_heard_buf, sizeof(last_heard_buf)); - char hop_buf[16]; + char hop_buf[64]; if (node.hops_away != 0xFF) { - snprintf(hop_buf, sizeof(hop_buf), "Hop: %u", static_cast(node.hops_away)); + if (node.next_hop != 0) + { + snprintf(hop_buf, + sizeof(hop_buf), + "Hop: %u / NH: %02X", + static_cast(node.hops_away), + static_cast(node.next_hop)); + } + else + { + snprintf(hop_buf, sizeof(hop_buf), "Hop: %u", static_cast(node.hops_away)); + } + set_label_text(s_widgets.link_hop_label, hop_buf); + } + else if (node.next_hop != 0) + { + snprintf(hop_buf, sizeof(hop_buf), "Hop: - / NH: %02X", static_cast(node.next_hop)); set_label_text(s_widgets.link_hop_label, hop_buf); } else { set_label_text(s_widgets.link_hop_label, "Hop: -"); } - set_label_text(s_widgets.link_last_heard_label, last_heard_buf); + + char status_buf[96]; + const char* mqtt_label = node.via_mqtt ? "MQTT" : "LoRa"; + const char* ignored_label = node.is_ignored ? "Ignored" : "Active"; + snprintf(status_buf, sizeof(status_buf), "%s / %s", mqtt_label, ignored_label); + set_label_text(s_widgets.link_last_heard_label, status_buf); + + if (node.has_public_key || node.key_manually_verified || node.has_device_metrics || node.hw_model != 0 || + node.has_macaddr || node.last_seen != 0) + { + char desc_buf[128]; + char pk_buf[20]; + if (node.key_manually_verified) + { + snprintf(pk_buf, sizeof(pk_buf), "PKI: verified"); + } + else if (node.has_public_key) + { + snprintf(pk_buf, sizeof(pk_buf), "PKI: known"); + } + else + { + snprintf(pk_buf, sizeof(pk_buf), "PKI: -"); + } + + if (node.has_device_metrics && node.device_metrics.has_battery_level) + { + snprintf(desc_buf, + sizeof(desc_buf), + "HW:%u BAT:%u%% %s", + static_cast(node.hw_model), + static_cast(node.device_metrics.battery_level), + pk_buf); + } + else + { + snprintf(desc_buf, + sizeof(desc_buf), + "HW:%u %s %s", + static_cast(node.hw_model), + node.has_macaddr ? "MAC" : "NO-MAC", + pk_buf); + } + set_label_text(s_widgets.updated_label, desc_buf); + lv_obj_clear_flag(s_widgets.location_updated, LV_OBJ_FLAG_HIDDEN); + } + else + { + set_label_text(s_widgets.updated_label, last_heard_buf); + lv_obj_clear_flag(s_widgets.location_updated, LV_OBJ_FLAG_HIDDEN); + } } } // namespace ui diff --git a/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp b/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp index 93fa25c4..9c429484 100644 --- a/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp @@ -8,15 +8,15 @@ * +------------------------------------------------------------------+ * | TopRow (ROW, grow) | * | +-------------------------+ +---------------------------------+ | - * | | Info Card (left) | | Location Card (right) | | - * | | +---------------------+ | | +---------------------------+ | | - * | | | Avatar (S) | | | | Title: Location | | | - * | | | Name: ALFA-3 | | | +---------------------------+ | | - * | | | Desc: Relay Node | | | | Map Placeholder | | | - * | | +---------------------+ | | | [Map image] | | | - * | | | ID: !a1b2c3 | | | +---------------------------+ | | - * | | | Role: Router | | | | Lat,Lon +/-Acc Alt | | | - * | | +---------------------+ | | +---------------------------+ | | + * | | Info Card (left) | | Location Card (right) | | + * | | +---------------------+ | | +---------------------------+ | | + * | | | Avatar (S) | | | | Title: Location | | | + * | | | Name: ALFA-3 | | | +---------------------------+ | | + * | | | Desc: Relay Node | | | | Map Placeholder | | | + * | | +---------------------+ | | | [Map image] | | | + * | | | ID: !a1b2c3 | | | +---------------------------+ | | + * | | | Role: Router | | | | Lat,Lon +/-Acc Alt | | | + * | | +---------------------+ | | +---------------------------+ | | * | | | | | Updated: 2m ago | | | * | +-------------------------+ | +---------------------------+ | | * | +---------------------------------+ | @@ -25,29 +25,29 @@ * | +------------------------------------------------------------+ | * | | Title: Link | | * | +------------------------------------------------------------+ | - * | | RSSI: -112 SNR: 7.5 Ch: 478.875 SF: 7 BW: 125k | | + * | | RSSI: -112 SNR: 7.5 Ch: 478.875 SF: 7 BW: 125k | | * | +------------------------------------------------------------+ | - * | | Hop: 2 Last heard: 18s | | + * | | Hop: 2 Last heard: 18s | | * | +------------------------------------------------------------+ | * +------------------------------------------------------------------+ * * Tree view: * Root(COL) - * 鈹溾攢 Header - * 鈹斺攢 Content(COL) - * 鈹溾攢 TopRow(ROW, grow=1) - * 鈹? 鈹溾攢 InfoCard(COL) - * 鈹? 鈹? 鈹溾攢 InfoHeader(ROW) - * 鈹? 鈹? 鈹斺攢 InfoFooter(ROW) - * 鈹? 鈹斺攢 LocationCard(COL, grow=1) - * 鈹? 鈹溾攢 LocationHeader - * 鈹? 鈹溾攢 LocationMap(grow=1) - * 鈹? 鈹溾攢 LocationCoords - * 鈹? 鈹斺攢 LocationUpdated - * 鈹斺攢 LinkPanel(COL) - * 鈹溾攢 LinkHeader - * 鈹溾攢 LinkRow1 - * 鈹斺攢 LinkRow2 + * - Header + * - Content(COL) + * - TopRow(ROW, grow=1) + * - InfoCard(COL) + * - InfoHeader(ROW) + * - InfoFooter(ROW) + * - LocationCard(COL, grow=1) + * - LocationHeader + * - LocationMap(grow=1) + * - LocationCoords + * - LocationUpdated + * - LinkPanel(COL) + * - LinkHeader + * - LinkRow1 + * - LinkRow2 * * Preconditions: * - Root uses LV_FLEX_FLOW_COLUMN. 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 9c871031..8e0618a8 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 @@ -9,6 +9,7 @@ #include "app/app_config.h" #include "app/app_facade_access.h" +#include "board/BoardBase.h" #include "chat/domain/chat_types.h" #include "chat/infra/meshcore/mc_region_presets.h" #include "chat/infra/meshtastic/mt_region.h" @@ -162,6 +163,22 @@ static void play_message_tone_preview() device_runtime::play_message_tone(); } +static constexpr int kScreenBrightnessMin = DEVICE_MIN_BRIGHTNESS_LEVEL; +static constexpr int kScreenBrightnessMax = DEVICE_MAX_BRIGHTNESS_LEVEL; + +static int clamp_screen_brightness(int level) +{ + if (level < kScreenBrightnessMin) + { + return kScreenBrightnessMin; + } + if (level > kScreenBrightnessMax) + { + return kScreenBrightnessMax; + } + return level; +} + static void apply_track_recording_runtime(bool enabled) { tracker_runtime::set_auto_recording(enabled); @@ -564,6 +581,8 @@ static void settings_load() g_settings.privacy_nmea_sentence = cfg.privacy_nmea_sentence; g_settings.screen_timeout_ms = prefs_get_int("screen_timeout", static_cast(screen_runtime::timeout_ms())); + g_settings.screen_brightness = clamp_screen_brightness( + prefs_get_int("screen_brightness", static_cast(device_runtime::screen_brightness()))); g_settings.timezone_offset_min = ::platform::ui::time::timezone_offset_min(); g_settings.speaker_volume = prefs_get_int("speaker_volume", static_cast(get_message_tone_volume_default())); @@ -579,6 +598,7 @@ static void settings_load() // BLE enable flag (System > Bluetooth toggle). Default ON so existing devices keep BLE active. g_settings.ble_enabled = prefs_get_bool("ble_enabled", true); + g_settings.vibration_enabled = prefs_get_bool("vibration_enabled", true); g_settings.advanced_debug_logs = prefs_get_bool("adv_debug", false); @@ -1124,6 +1144,12 @@ static void on_option_clicked(lv_event_t* e) { screen_runtime::set_timeout_ms(static_cast(payload->value)); } + if (payload->item->pref_key && strcmp(payload->item->pref_key, "screen_brightness") == 0) + { + const uint8_t brightness = static_cast(clamp_screen_brightness(payload->value)); + g_settings.screen_brightness = brightness; + device_runtime::set_screen_brightness(brightness); + } if (payload->item->pref_key && strcmp(payload->item->pref_key, "speaker_volume") == 0) { const uint8_t volume = static_cast(payload->value); @@ -1634,6 +1660,14 @@ static const settings::ui::SettingOption kScreenTimeoutOptions[] = { {"Always", 300000}, }; +static const settings::ui::SettingOption kScreenBrightnessOptions[] = { + {"OFF", 0}, + {"25%", 4}, + {"50%", 8}, + {"75%", 12}, + {"100%", 16}, +}; + static const settings::ui::SettingOption kSpeakerVolumeOptions[] = { {"OFF", 0}, {"30%", 30}, @@ -1739,8 +1773,11 @@ static settings::ui::SettingItem kNetworkItems[] = { static settings::ui::SettingItem kScreenItems[] = { {"Screen Timeout", settings::ui::SettingType::Enum, kScreenTimeoutOptions, 4, &g_settings.screen_timeout_ms, nullptr, nullptr, 0, false, "screen_timeout"}, + {"Screen Brightness", settings::ui::SettingType::Enum, kScreenBrightnessOptions, + sizeof(kScreenBrightnessOptions) / sizeof(kScreenBrightnessOptions[0]), &g_settings.screen_brightness, nullptr, nullptr, 0, false, "screen_brightness"}, {"Speaker Volume", settings::ui::SettingType::Enum, kSpeakerVolumeOptions, sizeof(kSpeakerVolumeOptions) / sizeof(kSpeakerVolumeOptions[0]), &g_settings.speaker_volume, nullptr, nullptr, 0, false, "speaker_volume"}, + {"Vibration", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.vibration_enabled, nullptr, 0, false, "vibration_enabled"}, {"Bluetooth", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.ble_enabled, nullptr, 0, false, "ble_enabled"}, {"Time Zone", settings::ui::SettingType::Enum, kTimeZoneOptions, sizeof(kTimeZoneOptions) / sizeof(kTimeZoneOptions[0]), &g_settings.timezone_offset_min, nullptr, nullptr, 0, false, "timezone_offset"}, {"Gauge Design (mAh)", settings::ui::SettingType::Text, nullptr, 0, nullptr, nullptr, @@ -1798,6 +1835,11 @@ static bool should_show_item(const settings::ui::SettingItem& item) return false; } + if (has_pref_key(item, "screen_brightness") && !device_runtime::supports_screen_brightness()) + { + return false; + } + if (meshcore) { if (has_pref_key(item, "chat_region")) return false; @@ -2023,6 +2065,10 @@ static void on_item_clicked(lv_event_t* e) app::IAppFacade& app_ctx = app::appFacade(); app_ctx.setBleEnabled(*item.bool_value); } + if (item.pref_key && strcmp(item.pref_key, "vibration_enabled") == 0 && *item.bool_value) + { + device_runtime::trigger_haptic(); + } } return; } diff --git a/modules/ui_shared/src/ui/screens/team/team_page_layout.cpp b/modules/ui_shared/src/ui/screens/team/team_page_layout.cpp index 852d4648..5401777a 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_layout.cpp @@ -3,17 +3,17 @@ * -------------------------------------------------------------------- * * Root (COLUMN) - * 鈹溾攢 Header (TopBar) - * 鈹斺攢 Content (COLUMN, flex-grow = 1) - * 鈹溾攢 Body (flex-grow = 1) - * 鈹斺攢 Actions (row) + * - Header (TopBar) + * - Content (COLUMN, flex-grow = 1) + * - Body (flex-grow = 1) + * - Actions (row) * * Tree view: * Root(COL) - * 鈹溾攢 Header - * 鈹斺攢 Content(COL) - * 鈹溾攢 Body(COL) - * 鈹斺攢 Actions(ROW) + * - Header + * - Content(COL) + * - Body(COL) + * - Actions(ROW) * * ASCII wireframe (layout only): * +----------------------------------------------------------+ @@ -78,14 +78,14 @@ lv_obj_t* create_content(lv_obj_t* root) { const auto& profile = ::ui::page_profile::current(); lv_obj_t* content = lv_obj_create(root); - lv_obj_set_size(content, LV_PCT(100), 0); + lv_obj_set_size(content, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_grow(content, 1); lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(content, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); lv_obj_set_style_pad_left(content, profile.content_pad_left, 0); lv_obj_set_style_pad_right(content, profile.content_pad_right, 0); lv_obj_set_style_pad_top(content, profile.content_pad_top, 0); lv_obj_set_style_pad_bottom(content, profile.content_pad_bottom, 0); + lv_obj_set_style_pad_row(content, profile.top_content_gap, 0); make_non_scrollable(content); return content; } @@ -93,21 +93,26 @@ lv_obj_t* create_content(lv_obj_t* root) lv_obj_t* create_body(lv_obj_t* content) { lv_obj_t* body = lv_obj_create(content); - lv_obj_set_size(body, LV_PCT(100), 0); + lv_obj_set_width(body, LV_PCT(100)); lv_obj_set_flex_grow(body, 1); lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); - lv_obj_set_scrollbar_mode(body, LV_SCROLLBAR_MODE_OFF); return body; } lv_obj_t* create_actions(lv_obj_t* content) { + const auto& profile = ::ui::page_profile::current(); lv_obj_t* actions = lv_obj_create(content); - lv_obj_set_size(actions, LV_PCT(100), LV_SIZE_CONTENT); + lv_coord_t action_height = profile.list_item_height; + const lv_coord_t control_height = ::ui::page_profile::resolve_control_button_height(); + if (control_height > action_height) + { + action_height = control_height; + } + lv_obj_set_width(actions, LV_PCT(100)); + lv_obj_set_height(actions, action_height + 8); lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(actions, LV_FLEX_ALIGN_SPACE_EVENLY, - LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_flex_align(actions, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); make_non_scrollable(actions); return actions; } diff --git a/platform/esp/arduino_common/include/ble/meshtastic_ble.h b/platform/esp/arduino_common/include/ble/meshtastic_ble.h index 46fcdd05..39e8c431 100644 --- a/platform/esp/arduino_common/include/ble/meshtastic_ble.h +++ b/platform/esp/arduino_common/include/ble/meshtastic_ble.h @@ -26,6 +26,7 @@ namespace ble class MeshtasticBleService : public BleService, public chat::ChatService::IncomingTextObserver, + public chat::ChatService::OutgoingTextObserver, public team::TeamService::IncomingDataObserver, public MeshtasticPhoneTransport, public MeshtasticPhoneHooks @@ -39,6 +40,7 @@ class MeshtasticBleService : public BleService, void update() override; void onIncomingText(const chat::MeshIncomingText& msg) override; + void onOutgoingText(const chat::MeshIncomingText& msg) override; void onIncomingData(const chat::MeshIncomingData& msg) override; bool isBleConnected() const override; void notifyFromNum(uint32_t value) override; 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 47aa0d7f..89af1db5 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 @@ -30,6 +30,9 @@ class MeshAdapterRouter : public IMeshAdapter MeshCapabilities getCapabilities() const override; bool sendText(ChannelId channel, const std::string& text, MessageId* out_msg_id, NodeId peer = 0) override; + bool sendTextWithId(ChannelId channel, const std::string& text, + MessageId forced_msg_id, + MessageId* out_msg_id, NodeId peer = 0) override; bool pollIncomingText(MeshIncomingText* out) override; bool sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h index 19d11173..98a7a192 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h @@ -50,6 +50,9 @@ class MtAdapter : public chat::IMeshAdapter bool sendText(ChannelId channel, const std::string& text, MessageId* out_msg_id, NodeId peer = 0) override; + bool sendTextWithId(ChannelId channel, const std::string& text, + MessageId forced_msg_id, + MessageId* out_msg_id, NodeId peer = 0) override; bool pollIncomingText(MeshIncomingText* out) override; bool sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h index a3aab3b4..a983f334 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/node_store.h @@ -20,6 +20,7 @@ class NodeStore : public contacts::INodeStore, NodeStore(); void begin() override; + void applyUpdate(uint32_t node_id, const contacts::NodeUpdate& update) override; void upsert(uint32_t node_id, const char* short_name, const char* long_name, uint32_t now_secs, float snr = 0.0f, float rssi = 0.0f, uint8_t protocol = 0, uint8_t role = contacts::kNodeRoleUnknown, uint8_t hops_away = 0xFF, @@ -29,6 +30,7 @@ class NodeStore : public contacts::INodeStore, bool remove(uint32_t node_id) override; const std::vector& getEntries() const override; void clear() override; + bool flush() override; private: static constexpr const char* kPersistNodesFile = "/nodes.bin"; diff --git a/platform/esp/arduino_common/include/sys/event_bus.h b/platform/esp/arduino_common/include/sys/event_bus.h index 582855de..2f3bdea1 100644 --- a/platform/esp/arduino_common/include/sys/event_bus.h +++ b/platform/esp/arduino_common/include/sys/event_bus.h @@ -13,6 +13,7 @@ #include #include "chat/domain/chat_types.h" +#include "chat/domain/contact_types.h" #include "team/domain/team_events.h" namespace sys @@ -130,12 +131,26 @@ struct NodeInfoUpdateEvent : public Event uint8_t hops_away; uint8_t hw_model; uint8_t channel; + bool has_macaddr; + uint8_t macaddr[6]; + bool via_mqtt; + bool is_ignored; + bool has_public_key; + bool key_manually_verified; + bool has_device_metrics; + chat::contacts::NodeDeviceMetrics device_metrics; NodeInfoUpdateEvent(uint32_t id, const char* sname, const char* lname, float s, float rssi_val, uint32_t ts, uint8_t proto, uint8_t r, uint8_t hops = 0xFF, uint8_t hw = 0, - uint8_t ch = 0xFF) + uint8_t ch = 0xFF, bool has_mac = false, const uint8_t* mac = nullptr, + bool via_mqtt_value = false, bool is_ignored_value = false, + bool has_pubkey = false, bool key_verified = false, + bool has_metrics = false, + const chat::contacts::NodeDeviceMetrics* metrics = nullptr) : Event(EventType::NodeInfoUpdate), node_id(id), snr(s), rssi(rssi_val), timestamp(ts), protocol(proto), - role(r), hops_away(hops), hw_model(hw), channel(ch) + role(r), hops_away(hops), hw_model(hw), channel(ch), has_macaddr(has_mac), macaddr{}, + via_mqtt(via_mqtt_value), is_ignored(is_ignored_value), has_public_key(has_pubkey), + key_manually_verified(key_verified), has_device_metrics(has_metrics), device_metrics{} { if (sname) { @@ -155,6 +170,14 @@ struct NodeInfoUpdateEvent : public Event { long_name[0] = '\0'; } + if (has_macaddr && mac) + { + memcpy(macaddr, mac, sizeof(macaddr)); + } + if (has_device_metrics && metrics) + { + device_metrics = *metrics; + } } }; diff --git a/platform/esp/arduino_common/src/LV_Helper_v9.cpp b/platform/esp/arduino_common/src/LV_Helper_v9.cpp index 60001df0..abe79dfd 100644 --- a/platform/esp/arduino_common/src/LV_Helper_v9.cpp +++ b/platform/esp/arduino_common/src/LV_Helper_v9.cpp @@ -39,6 +39,7 @@ static lv_color16_t* buf1 = nullptr; static void disp_flush(lv_display_t* disp_drv, const lv_area_t* area, uint8_t* color_p) { + static uint8_t s_tdeck_pro_flush_log_count = 0; #if LV_TEST_FLUSH_LOG static uint32_t s_flush_count = 0; static uint32_t s_flush_last_ms = 0; @@ -56,6 +57,33 @@ static void disp_flush(lv_display_t* disp_drv, const lv_area_t* area, uint8_t* c lv_draw_sw_rgb565_swap(color_p, len); #endif +#if defined(ARDUINO_T_DECK_PRO) + if (s_tdeck_pro_flush_log_count < 8) + { + uint32_t blackish = 0; + if (color_p != nullptr) + { + const uint16_t* pix = reinterpret_cast(color_p); + for (size_t i = 0; i < len; ++i) + { + if (pix[i] == 0x0000) + { + blackish++; + } + } + } + Serial.printf("[LVGL][TDeckPro] flush #%u area=(%d,%d)-(%d,%d) len=%lu rgb565_black=%lu\n", + static_cast(s_tdeck_pro_flush_log_count + 1), + static_cast(area->x1), + static_cast(area->y1), + static_cast(area->x2), + static_cast(area->y2), + static_cast(len), + static_cast(blackish)); + s_tdeck_pro_flush_log_count++; + } +#endif + plane->pushColors(area->x1, area->y1, w, h, (uint16_t*)color_p); lv_display_flush_ready(disp_drv); @@ -118,7 +146,7 @@ static void touchpad_read(lv_indev_t* drv, lv_indev_data_t* data) if (touched) { input::MorseEngine::notifyTouch(); -#if defined(ARDUINO_T_DECK) +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) // T-Deck: touch can wake/show the screen saver, but only SPACE enters the main menu. if (isScreenSaverActive()) { @@ -138,7 +166,7 @@ static void touchpad_read(lv_indev_t* drv, lv_indev_data_t* data) data->state = LV_INDEV_STATE_PR; return; #else - // 閸忔湹绮拋鎯ь槵娴犲秶鍔ф穱婵囧瘮閳ユ粌鍘涢崬銈夊晪閵嗕椒绗夋导鐘电舶 UI閳ユ繄娈戦柅鏄忕帆閵? // Priority: if screen saver is visible, consume the touch to exit it. + // Priority: if the screen saver is visible, consume the touch and exit it. if (isScreenSaverActive()) { enterFromScreenSaver(); @@ -172,7 +200,7 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) { auto* plane = (LilyGo_Display*)lv_indev_get_user_data(drv); RotaryMsg_t msg = plane->getRotary(); -#if defined(ARDUINO_T_DECK) +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) data->enc_diff = 0; data->state = LV_INDEV_STATE_RELEASED; #endif @@ -184,7 +212,7 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) { wakeScreenSaver(); } -#if defined(ARDUINO_T_DECK) +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) // T-Deck path already reset above. #else data->enc_diff = 0; @@ -196,7 +224,7 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) // If GPS is loading tiles, ignore input if (isGPSLoadingTiles()) { -#if !defined(ARDUINO_T_DECK) +#if !defined(ARDUINO_T_DECK) && !defined(ARDUINO_T_DECK_PRO) data->enc_diff = 0; data->state = LV_INDEV_STATE_RELEASED; #endif @@ -224,7 +252,7 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) return; } -#if !defined(ARDUINO_T_DECK) +#if !defined(ARDUINO_T_DECK) && !defined(ARDUINO_T_DECK_PRO) data->enc_diff = 0; data->state = LV_INDEV_STATE_RELEASED; #endif @@ -246,7 +274,7 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) data->enc_diff = -1; break; default: -#if !defined(ARDUINO_T_DECK) +#if !defined(ARDUINO_T_DECK) && !defined(ARDUINO_T_DECK_PRO) data->state = LV_INDEV_STATE_RELEASED; #endif break; @@ -268,33 +296,21 @@ static void keypad_read(lv_indev_t* drv, lv_indev_data_t* data) // If screen is sleeping or screen saver is active, only a *real* key press // should wake/exit. Previously this path ran unconditionally on every poll, - // which could cause spurious wake閳姀nterFromScreenSaver() without user input. + // which could cause a spurious wake or enterFromScreenSaver() without user input. if (isScreenSleeping() || isScreenSaverActive()) { - if (state == KEYBOARD_PRESSED || state == KEYBOARD_RELEASED) + if (state == KEYBOARD_PRESSED) { -#if defined(ARDUINO_T_DECK) - // T-Deck policy: any key can wake into the saver shell, and SPACE can continue into the main menu. - // Accept both press/release for SPACE so a quick tap from sleep can still land in the menu. + // Keyboard wake policy: any key can wake into the saver shell, but + // only SPACE is allowed to continue into the main menu. if (isScreenSaverActive() && c == ' ') { enterFromScreenSaver(); } - else if (state == KEYBOARD_PRESSED) + else { wakeScreenSaver(); } -#else - // Other keyboard devices keep the original behavior: - // first key wakes the screen saver, and any key exits it. - if (state == KEYBOARD_PRESSED) - { - if (isScreenSaverActive()) - enterFromScreenSaver(); - else - wakeScreenSaver(); - } -#endif } data->state = LV_INDEV_STATE_REL; // Don't pass key to UI return; @@ -306,7 +322,7 @@ static void keypad_read(lv_indev_t* drv, lv_indev_data_t* data) walkie::on_key_event(c, state); } -#if defined(ARDUINO_T_DECK) +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) if (state == KEYBOARD_PRESSED && c == ' ' && ui_get_active_app() == nullptr) { updateUserActivity(); 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 e7b94ade..2b23ed82 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -8,6 +8,7 @@ #include "chat/usecase/contact_service.h" #include "platform/esp/arduino_common/app_runtime_support.h" #include "platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h" +#include "platform/ui/settings_store.h" #include "sys/event_bus.h" #include "team/protocol/team_chat.h" #include "ui/chat_ui_runtime.h" @@ -26,7 +27,10 @@ void triggerMessageFeedback(app::IAppFacade& app_context) { return; } - board->vibrator(); + if (platform::ui::settings_store::get_bool("settings", "vibration_enabled", true)) + { + board->vibrator(); + } board->playMessageTone(); } @@ -104,7 +108,7 @@ void handleTeamChatNotification(app::IAppFacade& app_context, const sys::TeamCha notice += "Message"; } - ui::SystemNotification::show(notice.c_str(), 3000); + ::ui::SystemNotification::show(notice.c_str(), 3000); } void tickUiRuntime(app::IAppFacade& app_context) @@ -133,7 +137,7 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) { auto* msg_event = static_cast(event); triggerMessageFeedback(app_context); - ui::SystemNotification::show(msg_event->text, 3000); + ::ui::SystemNotification::show(msg_event->text, 3000); break; } case sys::EventType::TeamChat: @@ -141,31 +145,49 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) break; case sys::EventType::KeyVerificationNumberRequest: { + chat::ui::IChatUiRuntime* chat_ui_runtime = app_context.getChatUiRuntime(); + if (chat_ui_runtime) + { + chat_ui_runtime->onChatEvent(event); + return true; + } auto* kv_event = static_cast(event); std::string msg = "Key verify: enter number for " + resolveContactName(app_context, kv_event->node_id); - ui::SystemNotification::show(msg.c_str(), 4000); + ::ui::SystemNotification::show(msg.c_str(), 4000); delete event; return true; } case sys::EventType::KeyVerificationNumberInform: { + chat::ui::IChatUiRuntime* chat_ui_runtime = app_context.getChatUiRuntime(); + if (chat_ui_runtime) + { + chat_ui_runtime->onChatEvent(event); + return true; + } auto* kv_event = static_cast(event); const std::string name = resolveContactName(app_context, kv_event->node_id); const uint32_t number = kv_event->security_number % 1000000; char number_buf[16]; snprintf(number_buf, sizeof(number_buf), "%03u %03u", number / 1000, number % 1000); std::string msg = "Key verify: " + name + " " + number_buf; - ui::SystemNotification::show(msg.c_str(), 5000); + ::ui::SystemNotification::show(msg.c_str(), 5000); delete event; return true; } case sys::EventType::KeyVerificationFinal: { + chat::ui::IChatUiRuntime* chat_ui_runtime = app_context.getChatUiRuntime(); + if (chat_ui_runtime) + { + chat_ui_runtime->onChatEvent(event); + return true; + } auto* kv_event = static_cast(event); std::string msg = std::string("Key verify: ") + (kv_event->is_sender ? "send " : "confirm ") + kv_event->verification_code + " " + resolveContactName(app_context, kv_event->node_id); - ui::SystemNotification::show(msg.c_str(), 5000); + ::ui::SystemNotification::show(msg.c_str(), 5000); delete event; return true; } diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index 860d9ab3..e0697224 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -54,6 +54,7 @@ void updateCoreServices(app::IAppFacade& app_context) hostlink::process_pending_commands(); app_context.getChatService().processIncoming(); + app_context.getChatService().flushStore(); team::TeamService* team_service = app_context.getTeamService(); if (team_service) @@ -95,18 +96,44 @@ bool dispatchEvent(app::IAppFacade& app_context, sys::Event* event) case sys::EventType::NodeInfoUpdate: { auto* node_event = static_cast(event); - app_context.getContactService().updateNodeInfo( - node_event->node_id, - node_event->short_name, - node_event->long_name, - node_event->snr, - node_event->rssi, - node_event->timestamp, - node_event->protocol, - node_event->role, - node_event->hops_away, - node_event->hw_model, - node_event->channel); + chat::contacts::NodeUpdate update{}; + update.short_name = node_event->short_name; + update.long_name = node_event->long_name; + update.has_last_seen = true; + update.last_seen = node_event->timestamp; + update.has_snr = true; + update.snr = node_event->snr; + update.has_rssi = true; + update.rssi = node_event->rssi; + update.has_protocol = true; + update.protocol = node_event->protocol; + update.has_role = true; + update.role = node_event->role; + update.has_hops_away = true; + update.hops_away = node_event->hops_away; + update.has_hw_model = true; + update.hw_model = node_event->hw_model; + update.has_channel = true; + update.channel = node_event->channel; + update.has_macaddr = node_event->has_macaddr; + if (node_event->has_macaddr) + { + memcpy(update.macaddr, node_event->macaddr, sizeof(update.macaddr)); + } + update.has_via_mqtt = true; + update.via_mqtt = node_event->via_mqtt; + update.has_is_ignored = true; + update.is_ignored = node_event->is_ignored; + update.has_public_key = true; + update.public_key_present = node_event->has_public_key; + update.has_key_manually_verified = true; + update.key_manually_verified = node_event->key_manually_verified; + update.has_device_metrics = node_event->has_device_metrics; + if (node_event->has_device_metrics) + { + update.device_metrics = node_event->device_metrics; + } + app_context.getContactService().applyNodeUpdate(node_event->node_id, update); delete event; return true; } diff --git a/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp b/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp index 8cc1874a..889e10d8 100644 --- a/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp +++ b/platform/esp/arduino_common/src/ble/meshtastic_ble.cpp @@ -3,6 +3,7 @@ #include "app/app_config.h" #include "ble/ble_uuids.h" #include "board/BoardBase.h" +#include "chat/ble/meshtastic_defaults.h" #include "chat/ble/meshtastic_phone_core.h" #include "chat/domain/contact_types.h" #include "chat/infra/meshtastic/mt_codec_pb.h" @@ -45,17 +46,10 @@ inline void ble_log(const char* fmt, ...) va_end(args); Serial.printf("[BLE] %s\n", buf); } -constexpr uint32_t kConfigNonceOnlyConfig = 69420; -constexpr uint32_t kConfigNonceOnlyNodes = 69421; - constexpr size_t kMaxFromRadio = meshtastic_FromRadio_size; constexpr size_t kMaxToRadio = meshtastic_ToRadio_size; -constexpr uint8_t kMaxMeshtasticChannels = 8; constexpr uint16_t kPreferredBleMtu = 517; constexpr bool kEnableFromRadioSync = false; -constexpr uint32_t kOfficialMinAppVersion = 30200; -constexpr uint32_t kOfficialDeviceStateVersion = 24; -constexpr const char* kCompatFirmwareVersion = "2.7.4.0"; const char* pairingModeName(meshtastic_Config_BluetoothConfig_PairingMode mode) { @@ -83,17 +77,6 @@ constexpr const char* kBlePinKey = "pin"; constexpr const char* kModulePrefsNs = "mt_mod"; constexpr const char* kModuleBlobKey = "cfg"; -constexpr uint32_t kModuleConfigVersion = 1; - -constexpr const char* kDefaultMqttAddress = "mqtt.meshtastic.org"; -constexpr const char* kDefaultMqttUsername = "meshdev"; -constexpr const char* kDefaultMqttPassword = "large4cats"; -constexpr const char* kDefaultMqttRoot = "msh"; -constexpr bool kDefaultMqttEncryptionEnabled = true; -constexpr bool kDefaultMqttTlsEnabled = false; -constexpr uint32_t kDefaultMapPublishIntervalSecs = 60 * 60; -constexpr uint32_t kDefaultDetectionMinBroadcastSecs = 45; -constexpr uint32_t kDefaultAmbientCurrent = 10; uint8_t xorHash(const uint8_t* data, size_t len) { @@ -456,6 +439,7 @@ void MeshtasticBleService::start() startAdvertising(); ctx_.getChatService().addIncomingTextObserver(this); + ctx_.getChatService().addOutgoingTextObserver(this); if (auto* team = ctx_.getTeamService()) { team->addIncomingDataObserver(this); @@ -467,6 +451,7 @@ void MeshtasticBleService::start() void MeshtasticBleService::stop() { ctx_.getChatService().removeIncomingTextObserver(this); + ctx_.getChatService().removeOutgoingTextObserver(this); if (auto* team = ctx_.getTeamService()) { team->removeIncomingDataObserver(this); @@ -523,6 +508,12 @@ void MeshtasticBleService::update() device_name_.c_str()); refreshBatteryLevel(true); syncMqttProxySettings(); + if (phone_session_) + { + // Drain mesh adapter app-data events (including synthetic ROUTING_APP + // ACK/NAK results) into the phone session before BLE reads them. + phone_session_->pumpIncomingAppData(); + } handleFromPhone(); handleToPhone(); } @@ -540,6 +531,23 @@ void MeshtasticBleService::onIncomingText(const chat::MeshIncomingText& msg) } } +void MeshtasticBleService::onOutgoingText(const chat::MeshIncomingText& msg) +{ + if (phone_session_) + { + ble_log("local text mirror id=%lu from=%08lX to=%08lX len=%u", + static_cast(msg.msg_id), + static_cast(msg.from), + static_cast(msg.to), + static_cast(msg.text.size())); + phone_session_->onIncomingText(msg); + if (phone_session_->isSendingPackets()) + { + notifyFromNum(0); + } + } +} + void MeshtasticBleService::onIncomingData(const chat::MeshIncomingData& msg) { if (phone_session_) diff --git a/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp b/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp index 5782859f..329f62e8 100644 --- a/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp +++ b/platform/esp/arduino_common/src/ble/meshtastic_ble_owner_hooks.cpp @@ -2,6 +2,7 @@ #include "app/app_config.h" #include "board/BoardBase.h" +#include "chat/ble/meshtastic_defaults.h" #include "chat/infra/meshtastic/mt_region.h" #include "platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h" @@ -22,13 +23,6 @@ constexpr const char* kModulePrefsNs = "mt_mod"; constexpr const char* kModuleBlobKey = "cfg"; constexpr uint32_t kModuleConfigVersion = 1; -constexpr const char* kDefaultMqttAddress = "mqtt.meshtastic.org"; -constexpr const char* kDefaultMqttUsername = "meshdev"; -constexpr const char* kDefaultMqttPassword = "large4cats"; -constexpr const char* kDefaultMqttRoot = "msh"; -constexpr bool kDefaultMqttEncryptionEnabled = true; -constexpr bool kDefaultMqttTlsEnabled = false; -constexpr uint32_t kDefaultMapPublishIntervalSecs = 60 * 60; constexpr uint32_t kDefaultDetectionMinBroadcastSecs = 45; constexpr uint32_t kDefaultAmbientCurrent = 10; @@ -249,14 +243,14 @@ void MeshtasticBleService::loadModuleConfig() module_config_.has_detection_sensor = true; module_config_.has_paxcounter = true; - copyBounded(module_config_.mqtt.address, sizeof(module_config_.mqtt.address), kDefaultMqttAddress); - copyBounded(module_config_.mqtt.username, sizeof(module_config_.mqtt.username), kDefaultMqttUsername); - copyBounded(module_config_.mqtt.password, sizeof(module_config_.mqtt.password), kDefaultMqttPassword); - copyBounded(module_config_.mqtt.root, sizeof(module_config_.mqtt.root), kDefaultMqttRoot); - module_config_.mqtt.encryption_enabled = kDefaultMqttEncryptionEnabled; - module_config_.mqtt.tls_enabled = kDefaultMqttTlsEnabled; + copyBounded(module_config_.mqtt.address, sizeof(module_config_.mqtt.address), meshtastic_defaults::kDefaultMqttAddress); + copyBounded(module_config_.mqtt.username, sizeof(module_config_.mqtt.username), meshtastic_defaults::kDefaultMqttUsername); + copyBounded(module_config_.mqtt.password, sizeof(module_config_.mqtt.password), meshtastic_defaults::kDefaultMqttPassword); + copyBounded(module_config_.mqtt.root, sizeof(module_config_.mqtt.root), meshtastic_defaults::kDefaultMqttRoot); + module_config_.mqtt.encryption_enabled = meshtastic_defaults::kDefaultMqttEncryptionEnabled; + module_config_.mqtt.tls_enabled = meshtastic_defaults::kDefaultMqttTlsEnabled; module_config_.mqtt.has_map_report_settings = true; - module_config_.mqtt.map_report_settings.publish_interval_secs = kDefaultMapPublishIntervalSecs; + module_config_.mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; module_config_.mqtt.map_report_settings.position_precision = 0; module_config_.mqtt.map_report_settings.should_report_location = false; @@ -372,7 +366,7 @@ void MeshtasticBleService::syncMqttProxySettings() settings.primary_downlink_enabled = cfg.primary_downlink_enabled; settings.secondary_uplink_enabled = cfg.secondary_uplink_enabled; settings.secondary_downlink_enabled = cfg.secondary_downlink_enabled; - settings.root = module_config_.mqtt.root[0] ? module_config_.mqtt.root : kDefaultMqttRoot; + settings.root = module_config_.mqtt.root[0] ? module_config_.mqtt.root : meshtastic_defaults::kDefaultMqttRoot; settings.primary_channel_id = channelDisplayName(ctx_, 0); settings.secondary_channel_id = channelDisplayName(ctx_, 1); mt->setMqttProxySettings(settings); diff --git a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp index aa9d5664..afe306cb 100644 --- a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp +++ b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp @@ -81,6 +81,14 @@ bool MeshAdapterRouter::sendText(ChannelId channel, const std::string& text, return lock.locked() && core_.sendText(channel, text, out_msg_id, peer); } +bool MeshAdapterRouter::sendTextWithId(ChannelId channel, const std::string& text, + MessageId forced_msg_id, + MessageId* out_msg_id, NodeId peer) +{ + LockGuard lock(mutex_); + return lock.locked() && core_.sendTextWithId(channel, text, forced_msg_id, out_msg_id, peer); +} + bool MeshAdapterRouter::pollIncomingText(MeshIncomingText* out) { LockGuard lock(mutex_); diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp index 6b7b793f..11f5f382 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp @@ -111,6 +111,16 @@ static const char* portName(uint32_t portnum) } } +void mt_diag_log(const char* fmt, ...) +{ + char buf[192] = {}; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + Serial.print(buf); +} + using chat::meshtastic::computeChannelHash; using chat::meshtastic::expandShortPsk; using chat::meshtastic::hasValidPosition; @@ -280,6 +290,13 @@ MtAdapter::~MtAdapter() bool MtAdapter::sendText(ChannelId channel, const std::string& text, MessageId* out_msg_id, NodeId peer) +{ + return sendTextWithId(channel, text, 0, out_msg_id, peer); +} + +bool MtAdapter::sendTextWithId(ChannelId channel, const std::string& text, + MessageId forced_msg_id, + MessageId* out_msg_id, NodeId peer) { if (!ready_ || text.empty() || !config_.tx_enabled) { @@ -296,12 +313,25 @@ bool MtAdapter::sendText(ChannelId channel, const std::string& text, pending.channel = out_channel; pending.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; pending.text = text; - pending.msg_id = next_packet_id_++; + pending.msg_id = (forced_msg_id != 0) ? forced_msg_id : next_packet_id_++; + if (forced_msg_id != 0 && forced_msg_id >= next_packet_id_) + { + next_packet_id_ = forced_msg_id + 1; + if (next_packet_id_ == 0) + { + next_packet_id_ = 1; + } + } pending.dest = (peer != 0) ? peer : 0xFFFFFFFF; pending.retry_count = 0; pending.last_attempt = 0; send_queue_.push(pending); + mt_diag_log("[MT][TX] queue text id=%08lX dest=%08lX ch=%u len=%u\n", + static_cast(pending.msg_id), + static_cast(pending.dest), + static_cast(out_channel), + static_cast(text.size())); LORA_LOG("[LORA] queue text ch=%u len=%u id=%lu\n", static_cast(channel), static_cast(text.size()), @@ -451,6 +481,14 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, #endif bool ok = (state == RADIOLIB_ERR_NONE); + mt_diag_log("[MT][TX] app id=%08lX dest=%08lX port=%u ok=%u air_ack=%u track_ack=%u len=%u\n", + static_cast(msg_id), + static_cast(dest_node), + static_cast(portnum), + ok ? 1U : 0U, + air_want_ack ? 1U : 0U, + track_ack ? 1U : 0U, + static_cast(wire_size)); LORA_LOG("[LORA] TX app port=%u len=%u want_resp=%u air_ack=%u track_ack=%u ok=%d\n", (unsigned)portnum, (unsigned)wire_size, @@ -1390,22 +1428,33 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) rx_meta.sf = radio_sf_; rx_meta.cr = radio_cr_; + mt_diag_log("[MT][RX] from=%08lX to=%08lX id=%08lX flags=0x%02X ch=%u next=%u relay=%u len=%u\n", + static_cast(header.from), + static_cast(header.to), + static_cast(header.id), + static_cast(header.flags), + static_cast(header.channel), + static_cast(header.next_hop), + static_cast(header.relay_node), + static_cast(payload_size)); + if (header.from == node_id_) { auto pending_it = pending_ack_ms_.find(header.id); - if (header.to == 0xFFFFFFFF && pending_it != pending_ack_ms_.end()) + if (header.to == kBroadcastNodeId && pending_it != pending_ack_ms_.end()) { ChannelId channel_id = (header.channel == secondary_channel_hash_) ? ChannelId::SECONDARY : ChannelId::PRIMARY; - uint32_t ack_from = (header.relay_node != 0) ? header.relay_node : node_id_; - LORA_LOG("[LORA] RX implicit ack via rebroadcast req=%08lX relay=%08lX\n", - (unsigned long)header.id, - (unsigned long)ack_from); + mt_diag_log("[MT][IMPLICIT_ACK] observed self-broadcast id=%08lX relay=%08lX next=%08lX ch=%u\n", + static_cast(header.id), + static_cast(header.relay_node), + static_cast(header.next_hop), + static_cast(header.channel)); pending_ack_ms_.erase(pending_it); pending_ack_dest_.erase(header.id); emitRoutingResultToPhone(header.id, meshtastic_Routing_Error_NONE, - ack_from, + node_id_, node_id_, channel_id, header.channel, @@ -1413,7 +1462,11 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) return; } - LORA_LOG("[LORA] RX self drop id=%08lX\n", (unsigned long)header.id); + LORA_LOG("[LORA] RX self drop id=%08lX to=%08lX relay=%08lX ch=%u\n", + static_cast(header.id), + static_cast(header.to), + static_cast(header.relay_node), + static_cast(header.channel)); return; } @@ -1606,10 +1659,31 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) } uint32_t now_secs = time(nullptr); + chat::contacts::NodeDeviceMetrics metrics{}; + const bool has_metrics = node.has_device_metrics; + if (has_metrics) + { + metrics.has_battery_level = node.device_metrics.has_battery_level; + metrics.battery_level = node.device_metrics.battery_level; + metrics.has_voltage = node.device_metrics.has_voltage; + metrics.voltage = node.device_metrics.voltage; + metrics.has_channel_utilization = node.device_metrics.has_channel_utilization; + metrics.channel_utilization = node.device_metrics.channel_utilization; + metrics.has_air_util_tx = node.device_metrics.has_air_util_tx; + metrics.air_util_tx = node.device_metrics.air_util_tx; + metrics.has_uptime_seconds = node.device_metrics.has_uptime_seconds; + metrics.uptime_seconds = node.device_metrics.uptime_seconds; + } sys::NodeInfoUpdateEvent* event = new sys::NodeInfoUpdateEvent( node_id, short_name, long_name, snr, rssi, now_secs, static_cast(chat::contacts::NodeProtocolType::Meshtastic), role, - hops_away, static_cast(node.user.hw_model), channel_index); + hops_away, static_cast(node.user.hw_model), channel_index, + node.has_user, node.has_user ? node.user.macaddr : nullptr, + node.via_mqtt || ((header.flags & chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0), + node.is_ignored, + node.has_user && node.user.public_key.size == 32, + node.is_key_manually_verified, + has_metrics, has_metrics ? &metrics : nullptr); bool published = sys::EventBus::publish(event, 0); if (published) { @@ -1669,7 +1743,13 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) sys::NodeInfoUpdateEvent* event = new sys::NodeInfoUpdateEvent( node_id, short_name, long_name, snr, rssi, now_secs, static_cast(chat::contacts::NodeProtocolType::Meshtastic), role, - hops_away, static_cast(user.hw_model), channel_index); + hops_away, static_cast(user.hw_model), channel_index, + true, user.macaddr, + ((header.flags & chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0), + false, + user.public_key.size == 32, + false, + false, nullptr); bool published = sys::EventBus::publish(event, 0); if (published) { @@ -1752,6 +1832,11 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) } pending_ack_ms_.erase(decoded.request_id); pending_ack_dest_.erase(decoded.request_id); + mt_diag_log("[MT][ACK] req=%08lX from=%08lX reason=%u ok=%u\n", + static_cast(decoded.request_id), + static_cast(header.from), + static_cast(routing.error_reason), + ok ? 1U : 0U); LORA_LOG("[LORA] RX ack reason=%u (%s)\n", static_cast(routing.error_reason), routingErrorName(routing.error_reason)); @@ -2003,8 +2088,15 @@ void MtAdapter::processSendQueue() { if (now - it->second >= ACK_TIMEOUT_MS) { - LORA_LOG("[LORA] RX ack timeout req=%08lX\n", - (unsigned long)it->first); + const auto dest_it = pending_ack_dest_.find(it->first); + const uint32_t dest = (dest_it != pending_ack_dest_.end()) ? dest_it->second : 0; + mt_diag_log("[MT][ACK_TIMEOUT] req=%08lX dest=%08lX age_ms=%lu\n", + static_cast(it->first), + static_cast(dest), + static_cast(now - it->second)); + LORA_LOG("[LORA] RX ack timeout req=%08lX dest=%08lX\n", + static_cast(it->first), + static_cast(dest)); pending_ack_dest_.erase(it->first); emitRoutingResultToPhone(it->first, meshtastic_Routing_Error_MAX_RETRANSMIT, @@ -3686,6 +3778,12 @@ void MtAdapter::emitRoutingResultToPhone(uint32_t request_id, return; } + mt_diag_log("[MT][ACK->BLE] req=%08lX from=%08lX to=%08lX reason=%u\n", + static_cast(request_id), + static_cast(from), + static_cast(to), + static_cast(reason)); + meshtastic_Routing routing = meshtastic_Routing_init_default; routing.which_variant = meshtastic_Routing_error_reason_tag; routing.error_reason = reason; diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp index 12385912..45716841 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/node_store.cpp @@ -78,6 +78,11 @@ void NodeStore::begin() core_.begin(); } +void NodeStore::applyUpdate(uint32_t node_id, const contacts::NodeUpdate& update) +{ + core_.applyUpdate(node_id, update); +} + void NodeStore::upsert(uint32_t node_id, const char* short_name, const char* long_name, uint32_t now_secs, float snr, float rssi, uint8_t protocol, uint8_t role, uint8_t hops_away, uint8_t hw_model, uint8_t channel) @@ -123,6 +128,11 @@ void NodeStore::clear() core_.clear(); } +bool NodeStore::flush() +{ + return core_.flush(); +} + bool NodeStore::loadBlob(std::vector& out) { out.clear(); diff --git a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp index 5e7507fa..265a52cd 100644 --- a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp @@ -42,6 +42,26 @@ void handle_low_battery(const BatteryInfo& info) platform::esp::arduino_common::handleLowBattery(info.level, info.charging); } +bool supports_screen_brightness() +{ + return true; +} + +uint8_t screen_brightness() +{ + return board.getBrightness(); +} + +void set_screen_brightness(uint8_t level) +{ + board.setBrightness(level); +} + +void trigger_haptic() +{ + board.vibrator(); +} + uint8_t default_message_tone_volume() { return board.getMessageToneVolume(); diff --git a/platform/esp/arduino_common/src/screen_sleep.cpp b/platform/esp/arduino_common/src/screen_sleep.cpp index cea66bcd..2a14d72b 100644 --- a/platform/esp/arduino_common/src/screen_sleep.cpp +++ b/platform/esp/arduino_common/src/screen_sleep.cpp @@ -36,6 +36,7 @@ TaskHandle_t s_screen_sleep_task_handle = nullptr; uint32_t s_last_user_activity_time = 0; bool s_screen_sleeping = false; bool s_screen_sleep_disabled = false; +uint8_t s_saved_screen_brightness = DEVICE_MAX_BRIGHTNESS_LEVEL; uint8_t s_saved_keyboard_brightness = 127; bool s_screen_saver_active = false; lv_obj_t* s_screen_saver_layer = nullptr; @@ -210,7 +211,7 @@ void init_screen_saver() s_screen_saver_hint_label = lv_label_create(s_screen_saver_layer); lv_obj_set_style_text_color(s_screen_saver_hint_label, lv_color_hex(0x8A6A3A), 0); lv_obj_set_style_text_font(s_screen_saver_hint_label, &lv_font_montserrat_14, 0); -#if defined(ARDUINO_T_DECK) +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) lv_label_set_text(s_screen_saver_hint_label, "Press SPACE to enter main menu"); #else lv_label_set_text(s_screen_saver_hint_label, "Press SPACE to enter"); @@ -242,7 +243,7 @@ void screenSleepTask(void* pvParameters) { s_screen_sleeping = false; board.exitScreenSleep(); - board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL); + board.setBrightness(s_saved_screen_brightness); if (board.hasKeyboard()) { board.keyboardSetBrightness(s_saved_keyboard_brightness); @@ -260,6 +261,7 @@ void screenSleepTask(void* pvParameters) s_saved_keyboard_brightness = board.keyboardGetBrightness(); board.keyboardSetBrightness(0); } + s_saved_screen_brightness = board.getBrightness(); board.setBrightness(0); board.enterScreenSleep(); } @@ -267,7 +269,7 @@ void screenSleepTask(void* pvParameters) { s_screen_sleeping = false; board.exitScreenSleep(); - board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL); + board.setBrightness(s_saved_screen_brightness); if (board.hasKeyboard()) { board.keyboardSetBrightness(s_saved_keyboard_brightness); @@ -404,11 +406,17 @@ void wakeScreenSaver() return; } + bool was_sleeping = false; if (s_activity_mutex != nullptr) { if (xSemaphoreTake(s_activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE) { + was_sleeping = s_screen_sleeping; s_screen_saver_active = true; + // Keep the runtime in the logical sleep state while the transient + // saver is visible. This matches the 0.1.13 behavior: waking only + // shows the saver shell, and if the user does nothing for 3s we go + // right back to sleep instead of being treated as fully awake. s_screen_sleeping = true; xSemaphoreGive(s_activity_mutex); } @@ -418,7 +426,20 @@ void wakeScreenSaver() lv_obj_clear_flag(s_screen_saver_layer, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_screen_saver_layer); lv_refr_now(nullptr); - board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL); + if (was_sleeping) + { + board.exitScreenSleep(); + board.setBrightness(s_saved_screen_brightness); + if (board.hasKeyboard()) + { + board.keyboardSetBrightness(s_saved_keyboard_brightness); + } + } + else + { + s_saved_screen_brightness = board.getBrightness(); + board.setBrightness(s_saved_screen_brightness); + } if (s_screen_saver_timer == nullptr) { @@ -471,7 +492,7 @@ void disableScreenSleep() if (s_screen_sleeping) { s_screen_sleeping = false; - board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL); + board.setBrightness(s_saved_screen_brightness); if (board.hasKeyboard()) { board.keyboardSetBrightness(s_saved_keyboard_brightness); @@ -517,6 +538,7 @@ void updateUserActivity() { bool woke_from_sleep = false; bool hide_saver = false; + bool restore_sleep_state = false; if (s_activity_mutex != nullptr) { if (xSemaphoreTake(s_activity_mutex, pdMS_TO_TICKS(10)) == pdTRUE) @@ -530,12 +552,8 @@ void updateUserActivity() if (s_screen_sleeping) { s_screen_sleeping = false; - board.setBrightness(DEVICE_MAX_BRIGHTNESS_LEVEL); - if (board.hasKeyboard()) - { - board.keyboardSetBrightness(s_saved_keyboard_brightness); - } woke_from_sleep = true; + restore_sleep_state = true; } xSemaphoreGive(s_activity_mutex); } @@ -544,6 +562,15 @@ void updateUserActivity() { hide_screen_saver_layer(); } + if (restore_sleep_state) + { + board.exitScreenSleep(); + board.setBrightness(s_saved_screen_brightness); + if (board.hasKeyboard()) + { + board.keyboardSetBrightness(s_saved_keyboard_brightness); + } + } if (woke_from_sleep) { notifyWakeFromSleep(); diff --git a/platform/esp/arduino_common/src/sstv/sstv_service.cpp b/platform/esp/arduino_common/src/sstv/sstv_service.cpp index c4164603..cbf4720d 100644 --- a/platform/esp/arduino_common/src/sstv/sstv_service.cpp +++ b/platform/esp/arduino_common/src/sstv/sstv_service.cpp @@ -4,7 +4,7 @@ #include #endif -#if (defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)) || defined(TRAIL_MATE_ESP_BOARD_TAB5) +#if (defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_SX1262)) || defined(TRAIL_MATE_ESP_BOARD_TAB5) #if defined(TRAIL_MATE_ESP_BOARD_TAB5) #include @@ -803,7 +803,11 @@ void render_line_from_buffer(uint8_t line_rgb[320][4], e_mode mode, uint16_t lin void sstv_task(void*) { +#if defined(TRAIL_MATE_ESP_BOARD_TAB5) + TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance(); +#else boards::tlora_pager::TLoRaPagerBoard* board = boards::tlora_pager::TLoRaPagerBoard::getInstance(); +#endif int prev_volume = -1; bool prev_out_mute = false; bool restore_amp = false; diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp index 513a4350..5efbea4d 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp @@ -1483,7 +1483,7 @@ void tick_gps_update(bool allow_map_refresh) static uint32_t last_refresh_ms = 0; const uint32_t TITLE_UPDATE_INTERVAL_MS = 30000; - const double MOVE_THRESHOLD_M = 15.0; // Ignore small jitter; can tune to ~10閳?0m. + const double MOVE_THRESHOLD_M = 15.0; // Ignore small jitter; can tune to about 10-20m. const uint32_t REFRESH_INTERVAL_MS = 2000; // Even if movement is slow, refresh at this interval. bool gps_ready = platform::ui::device::gps_ready(); diff --git a/platform/esp/arduino_common/src/walkie/walkie_service.cpp b/platform/esp/arduino_common/src/walkie/walkie_service.cpp index 2b095c31..c5698097 100644 --- a/platform/esp/arduino_common/src/walkie/walkie_service.cpp +++ b/platform/esp/arduino_common/src/walkie/walkie_service.cpp @@ -8,7 +8,7 @@ #include "esp_efuse.h" #endif -#if (defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC)) || defined(TRAIL_MATE_ESP_BOARD_TAB5) +#if (defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_SX1262)) || defined(TRAIL_MATE_ESP_BOARD_TAB5) #include "app/app_config.h" #include "app/app_facade_access.h" diff --git a/platform/esp/arduino_common/src/walkie_runtime.cpp b/platform/esp/arduino_common/src/walkie_runtime.cpp index 66e4d5c9..03ff0905 100644 --- a/platform/esp/arduino_common/src/walkie_runtime.cpp +++ b/platform/esp/arduino_common/src/walkie_runtime.cpp @@ -1,9 +1,8 @@ #include "platform/esp/common/walkie_runtime.h" -#if defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_SX1262) && defined(USING_AUDIO_CODEC) +#if defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_SX1262) #include #include -#include #include "boards/tlora_pager/tlora_pager_board.h" #include "platform/esp/arduino_common/app_tasks.h" @@ -19,9 +18,9 @@ constexpr float kFskRxBwKHz = 156.2f; constexpr uint16_t kFskPreambleLen = 16; constexpr uint8_t kFskSyncWord[] = {0x2D, 0x01}; -boards::tlora_pager::TLoRaPagerBoard* resolveBoard(const Session* session) +::boards::tlora_pager::TLoRaPagerBoard* resolveBoard(const Session* session) { - return session ? static_cast(session->impl) : nullptr; + return session ? static_cast<::boards::tlora_pager::TLoRaPagerBoard*>(session->impl) : nullptr; } void writeError(char* error_buffer, size_t error_buffer_size, const char* message) @@ -92,85 +91,50 @@ bool isValid(const Session& session) bool isRadioReady(const Session& session) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(&session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(&session); return board && board->isRadioOnline(); } bool isCodecReady(const Session& session) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(&session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(&session); return board && ((board->getDevicesProbe() & HW_CODEC_ONLINE) != 0); } bool configureFsk(Session* session, float freq_mhz, int8_t tx_power, char* error_buffer, size_t error_buffer_size) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (!board || !board->isRadioOnline()) { writeError(error_buffer, error_buffer_size, "Radio offline"); return false; } - if (!board->lock(pdMS_TO_TICKS(200))) - { - writeError(error_buffer, error_buffer_size, "Radio lock failed"); - return false; - } - - board->radio_.standby(); - int state = board->radio_.beginFSK(freq_mhz, - kFskBitRateKbps, - kFskFreqDevKHz, - kFskRxBwKHz, - tx_power, - kFskPreambleLen, - 1.6f); + int state = board->configureFskRadio(freq_mhz, + kFskBitRateKbps, + kFskFreqDevKHz, + kFskRxBwKHz, + tx_power, + kFskPreambleLen, + 1.6f, + kFskSyncWord, + sizeof(kFskSyncWord), + 2); if (state != RADIOLIB_ERR_NONE) { - Serial.printf("[WALKIE] beginFSK failed state=%d\n", state); + Serial.printf("[WALKIE] configureFskRadio failed state=%d\n", state); writeStateError(error_buffer, error_buffer_size, "beginFSK fail", state); - board->unlock(); return false; } - uint8_t sync[sizeof(kFskSyncWord)]; - memcpy(sync, kFskSyncWord, sizeof(sync)); - state = board->radio_.setSyncWord(sync, sizeof(sync)); - if (state != RADIOLIB_ERR_NONE) - { - Serial.printf("[WALKIE] setSyncWord failed state=%d\n", state); - writeStateError(error_buffer, error_buffer_size, "setSync fail", state); - board->unlock(); - return false; - } - - state = board->radio_.setCRC(2); - if (state != RADIOLIB_ERR_NONE) - { - Serial.printf("[WALKIE] setCRC failed state=%d\n", state); - writeStateError(error_buffer, error_buffer_size, "setCRC fail", state); - board->unlock(); - return false; - } - - state = board->radio_.setPreambleLength(kFskPreambleLen); - if (state != RADIOLIB_ERR_NONE) - { - Serial.printf("[WALKIE] setPreamble failed state=%d\n", state); - writeStateError(error_buffer, error_buffer_size, "setPre fail", state); - board->unlock(); - return false; - } - - board->unlock(); session->lora_mode_configured = true; return true; } bool restoreLora(Session* session, char* error_buffer, size_t error_buffer_size) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (!board) { return false; @@ -179,14 +143,7 @@ bool restoreLora(Session* session, char* error_buffer, size_t error_buffer_size) { return true; } - if (!board->lock(pdMS_TO_TICKS(200))) - { - writeError(error_buffer, error_buffer_size, "Radio lock failed"); - return false; - } - - int state = board->radio_.begin(); - board->unlock(); + int state = board->restoreLoRaRadio(); if (state != RADIOLIB_ERR_NONE) { Serial.printf("[WALKIE] restore LoRa failed state=%d\n", state); @@ -200,7 +157,7 @@ bool restoreLora(Session* session, char* error_buffer, size_t error_buffer_size) int codecOpen(Session* session, uint8_t bits_per_sample, uint8_t channels, uint32_t sample_rate) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (!board) { return -1; @@ -215,7 +172,7 @@ int codecOpen(Session* session, uint8_t bits_per_sample, uint8_t channels, uint3 void codecClose(Session* session) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (!board || !session->codec_open) { return; @@ -226,19 +183,19 @@ void codecClose(Session* session) int codecRead(Session* session, uint8_t* buffer, size_t size) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); return board ? board->codec.read(buffer, size) : -1; } int codecWrite(Session* session, uint8_t* buffer, size_t size) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); return board ? board->codec.write(buffer, size) : -1; } void codecSetVolume(Session* session, uint8_t level) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (board) { board->codec.setVolume(level); @@ -247,7 +204,7 @@ void codecSetVolume(Session* session, uint8_t level) void codecSetGain(Session* session, float db_value) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (board) { board->codec.setGain(db_value); @@ -256,7 +213,7 @@ void codecSetGain(Session* session, float db_value) void codecSetMute(Session* session, bool enabled) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (board) { board->codec.setMute(enabled); @@ -265,44 +222,38 @@ void codecSetMute(Session* session, bool enabled) void standby(Session* session) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (board) { - board->radio_.standby(); + (void)board->radioStandby(); } } int startTransmit(Session* session, const uint8_t* data, size_t size) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (!board) { return -1; } - if (!board->lock(pdMS_TO_TICKS(50))) - { - return -1; - } - int state = board->radio_.startTransmit(const_cast(data), size); - board->unlock(); - return state; + return board->startRadioTransmit(data, size); } int startReceive(Session* session) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); return board ? board->startRadioReceive() : -1; } uint32_t getRadioIrqFlags(Session* session) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); return board ? board->getRadioIrqFlags() : 0; } void clearRadioIrqFlags(Session* session, uint32_t flags) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); if (board) { board->clearRadioIrqFlags(flags); @@ -311,13 +262,13 @@ void clearRadioIrqFlags(Session* session, uint32_t flags) int getPacketLength(Session* session, bool update) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); return board ? board->getRadioPacketLength(update) : -1; } int readRadioData(Session* session, uint8_t* buffer, size_t size) { - boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); + ::boards::tlora_pager::TLoRaPagerBoard* board = resolveBoard(session); return board ? board->readRadioData(buffer, size) : -1; } diff --git a/platform/esp/boards/src/board_runtime.cpp b/platform/esp/boards/src/board_runtime.cpp index 67cbb954..fefa8dd4 100644 --- a/platform/esp/boards/src/board_runtime.cpp +++ b/platform/esp/boards/src/board_runtime.cpp @@ -4,6 +4,8 @@ #include "boards/tab5/platform_esp_board_runtime.h" #elif defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) #include "boards/t_display_p4/platform_esp_board_runtime.h" +#elif defined(ARDUINO_T_DECK_PRO) +#include "boards/tdeck_pro/platform_esp_board_runtime.h" #elif defined(ARDUINO_T_DECK) #include "boards/tdeck/platform_esp_board_runtime.h" #elif defined(ARDUINO_T_WATCH_S3) diff --git a/platform/esp/boards/src/display/drivers/ST7789TDeck.cpp b/platform/esp/boards/src/display/drivers/ST7789TDeck.cpp index 2ae8d0d6..8d59240a 100644 --- a/platform/esp/boards/src/display/drivers/ST7789TDeck.cpp +++ b/platform/esp/boards/src/display/drivers/ST7789TDeck.cpp @@ -6,11 +6,12 @@ namespace drivers { // Command sequence derived from LilyGo T-Deck TFT_eSPI (INIT_SEQUENCE_2). +// Orientation is intentionally not fixed here because DisplayInterface::init() +// applies setRotation(0), and the board then selects its preferred rotation. // The high bit (0x80) in len requests a post-command delay in DisplayInterface. static const CommandTable_t kInit[] = { {0x11, {0}, 0x80}, // SLPOUT + delay {0x13, {0}, 0}, // NORON - {0x36, {0x00}, 1}, // MADCTL (RGB order) {0x3A, {0x55}, 1}, // COLMOD (RGB565) {0xB2, {0x0C, 0x0C, 0x00, 0x33, 0x33}, 5}, // PORCTRL {0xB7, {0x75}, 1}, // GCTRL diff --git a/platform/esp/boards/src/display/drivers/ST7789WatchS3.cpp b/platform/esp/boards/src/display/drivers/ST7789WatchS3.cpp index 4545d97f..c75f16d5 100644 --- a/platform/esp/boards/src/display/drivers/ST7789WatchS3.cpp +++ b/platform/esp/boards/src/display/drivers/ST7789WatchS3.cpp @@ -6,11 +6,12 @@ namespace drivers { // Command sequence derived from LilyGo ST7789 init (240x240 panel). +// Orientation is intentionally not fixed here because DisplayInterface::init() +// applies setRotation(0), and the board then selects its preferred rotation. // The high bit (0x80) in len requests a post-command delay in DisplayInterface. static const CommandTable_t kInit[] = { {0x11, {0}, 0x80}, // SLPOUT + delay {0x13, {0}, 0}, // NORON - {0x36, {0x00}, 1}, // MADCTL (RGB order) {0x3A, {0x55}, 1}, // COLMOD (RGB565) {0xB2, {0x0C, 0x0C, 0x00, 0x33, 0x33}, 5}, // PORCTRL {0xB7, {0x75}, 1}, // GCTRL diff --git a/platform/esp/boards/src/display/drivers/ST7796.cpp b/platform/esp/boards/src/display/drivers/ST7796.cpp index 81ccd1cd..a2a427e1 100644 --- a/platform/esp/boards/src/display/drivers/ST7796.cpp +++ b/platform/esp/boards/src/display/drivers/ST7796.cpp @@ -5,14 +5,15 @@ namespace display namespace drivers { -// ST7796 initialization command sequence +// ST7796 initialization command sequence. Orientation is intentionally not +// fixed here because DisplayInterface::init() applies setRotation(0) after +// sending this table. static const CommandTable_t st7796_init_commands[] = { {0x01, {0x00}, 0x80}, // Software reset, delay 120ms {0x11, {0x00}, 0x80}, // Sleep out, delay 120ms {0xF0, {0xC3}, 0x01}, // Command Set Control 1 {0xF0, {0xC3}, 0x01}, // Command Set Control 1 {0xF0, {0x96}, 0x01}, // Command Set Control 1 - {0x36, {0x48}, 0x01}, // Memory Access Control {0x3A, {0x55}, 0x01}, // Pixel Format Set (16-bit/pixel) {0xB4, {0x01}, 0x01}, // Display Inversion Control {0xB6, {0x80, 0x02, 0x3B}, 0x03}, // Display Function Control @@ -20,9 +21,9 @@ static const CommandTable_t st7796_init_commands[] = { {0xC1, {0x06}, 0x01}, // Power Control 2 {0xC2, {0xA7}, 0x01}, // Power Control 3 {0xC5, {0x18}, 0x81}, // VCOM Control, delay 120ms - {0xE0, {0xF0, 0x09, 0x0b, 0x06, 0x04, 0x15, 0x2F, 0x54, 0x42, 0x3C, 0x17, 0x14, 0x18, 0x1B}, 0x0F}, // Positive Voltage Gamma Control - {0xE1, {0xE0, 0x09, 0x0b, 0x06, 0x04, 0x03, 0x2B, 0x43, 0x42, 0x3B, 0x16, 0x14, 0x17, 0x1B}, 0x8F}, // Negative Voltage Gamma Control - {0xF0, {0x3c}, 0x01}, // Command Set Control 1 + {0xE0, {0xF0, 0x09, 0x0B, 0x06, 0x04, 0x15, 0x2F, 0x54, 0x42, 0x3C, 0x17, 0x14, 0x18, 0x1B}, 0x0F}, // Positive Voltage Gamma Control + {0xE1, {0xE0, 0x09, 0x0B, 0x06, 0x04, 0x03, 0x2B, 0x43, 0x42, 0x3B, 0x16, 0x14, 0x17, 0x1B}, 0x8F}, // Negative Voltage Gamma Control + {0xF0, {0x3C}, 0x01}, // Command Set Control 1 {0xF0, {0x69}, 0x81}, // Command Set Control 1, delay 120ms {0x21, {0x00}, 0x01}, // Display Inversion On {0x29, {0x00}, 0x01}, // Display On @@ -42,24 +43,21 @@ const DispRotationConfig_t* ST7796::getRotationConfig(uint16_t width, uint16_t h uint16_t landscape_offset_x, uint16_t portrait_offset_y) { - // Rotation configurations for ST7796 // Format: {MADCTL, width, height, offset_x, offset_y} - // MADCTL values control display orientation and color order - // - // Portrait orientations (0°, 180°): use portrait_offset_y for vertical offset - // Landscape orientations (90°, 270°): use landscape_offset_x for horizontal offset + // Rotations that swap logical width/height use portrait_offset_y. + // Rotations that keep logical width/height use landscape_offset_x. static DispRotationConfig_t rotation_configs[4]; - // Portrait 0° (rotated): width=height, height=width, offset_x=0, offset_y=portrait_offset_y + // rotation=0: default logical landscape for Pager-style 222x480 raw panels rotation_configs[0] = {0xE8, height, width, 0, portrait_offset_y}; - // Landscape 90° (normal): width=width, height=height, offset_x=landscape_offset_x, offset_y=0 + // rotation=1: alternate logical portrait orientation rotation_configs[1] = {0x48, width, height, landscape_offset_x, 0}; - // Portrait 180° (rotated): width=height, height=width, offset_x=0, offset_y=portrait_offset_y + // rotation=2: flipped logical landscape orientation rotation_configs[2] = {0x28, height, width, 0, portrait_offset_y}; - // Landscape 270° (upside down): width=width, height=height, offset_x=landscape_offset_x, offset_y=0 + // rotation=3: flipped logical portrait orientation rotation_configs[3] = {0x88, width, height, landscape_offset_x, 0}; return rotation_configs; diff --git a/platform/esp/idf_common/src/gps_runtime.cpp b/platform/esp/idf_common/src/gps_runtime.cpp index c7b325a4..fb85b321 100644 --- a/platform/esp/idf_common/src/gps_runtime.cpp +++ b/platform/esp/idf_common/src/gps_runtime.cpp @@ -496,17 +496,15 @@ void append_bytes_and_process(const uint8_t* buffer, int length, char* line_buff } } -void ensure_ext5v_enabled() +bool configure_uart_hardware() { #if defined(TRAIL_MATE_ESP_BOARD_TAB5) - (void)::boards::tab5::Tab5Board::instance().acquireExt5vRail("gps_runtime"); -#endif -} - -void configure_uart_hardware() -{ -#if defined(TRAIL_MATE_ESP_BOARD_TAB5) - (void)::boards::tab5::Tab5Board::instance().configureGpsUart(); + if (!::boards::tab5::Tab5Board::instance().configureGpsUart()) + { + ESP_LOGE(kTag, "Tab5 GNSS UART setup failed"); + return false; + } + return true; #else const auto gps_uart = gps_uart_pins(); uart_config_t config{}; @@ -521,14 +519,14 @@ void configure_uart_hardware() ESP_ERROR_CHECK(uart_set_pin(static_cast(gps_uart.port), gps_uart.tx, gps_uart.rx, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE)); ESP_ERROR_CHECK(uart_driver_install(static_cast(gps_uart.port), 4096, 0, 0, nullptr, 0)); ESP_LOGI(kTag, "GNSS UART configured: port=%d tx=%d rx=%d baud=%d", gps_uart.port, gps_uart.tx, gps_uart.rx, config.baud_rate); + return true; #endif } void teardown_uart_hardware() { #if defined(TRAIL_MATE_ESP_BOARD_TAB5) - ::boards::tab5::Tab5Board::instance().teardownGpsUart(); - ::boards::tab5::Tab5Board::instance().releaseExt5vRail("gps_runtime"); + // Tab5 keeps GNSS power and UART under board ownership for the whole runtime. #else const auto gps_uart = gps_uart_pins(); if (gps_uart.port >= 0) @@ -540,9 +538,18 @@ void teardown_uart_hardware() void worker_task(void*) { - ensure_ext5v_enabled(); vTaskDelay(pdMS_TO_TICKS(kProbeWarmupMs)); - configure_uart_hardware(); + if (!configure_uart_hardware()) + { + std::lock_guard lock(s_mutex); + s_runtime.powered = false; + s_runtime.stop_requested = false; + s_runtime.probe_requested = false; + s_runtime.probe_deadline_ms = 0; + s_runtime.worker_handle = nullptr; + vTaskDelete(nullptr); + return; + } { std::lock_guard lock(s_mutex); s_runtime.powered = true; 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 94eadcb9..1be25c24 100644 --- a/platform/esp/idf_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_device_runtime.cpp @@ -2,6 +2,7 @@ #include +#include "board/BoardBase.h" #include "boards/tab5/rtc_runtime.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" @@ -15,6 +16,13 @@ namespace platform::ui::device { +namespace +{ + +uint8_t s_brightness_level = DEVICE_MAX_BRIGHTNESS_LEVEL; + +} // namespace + void delay_ms(uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); @@ -44,6 +52,28 @@ void handle_low_battery(const BatteryInfo& info) (void)info; } +bool supports_screen_brightness() +{ + return true; +} + +uint8_t screen_brightness() +{ + return s_brightness_level; +} + +void set_screen_brightness(uint8_t level) +{ + s_brightness_level = level; + const int percent = (DEVICE_MAX_BRIGHTNESS_LEVEL <= 0) + ? 100 + : static_cast((static_cast(level) * 100U) / + static_cast(DEVICE_MAX_BRIGHTNESS_LEVEL)); + (void)platform::esp::idf_common::bsp_runtime::set_display_brightness(percent); +} + +void trigger_haptic() {} + uint8_t default_message_tone_volume() { return 45; diff --git a/platform/esp/idf_common/src/screen_sleep.cpp b/platform/esp/idf_common/src/screen_sleep.cpp index 3be7d372..3bb5403c 100644 --- a/platform/esp/idf_common/src/screen_sleep.cpp +++ b/platform/esp/idf_common/src/screen_sleep.cpp @@ -7,12 +7,14 @@ #include +#include "board/BoardBase.h" #include "esp_log.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #include "platform/esp/idf_common/bsp_runtime.h" +#include "platform/ui/device_runtime.h" #include "platform/ui/settings_store.h" #if defined(TRAIL_MATE_ESP_BOARD_TAB5) @@ -43,6 +45,7 @@ uint32_t s_last_user_activity_ms = 0; bool s_screen_sleeping = false; bool s_screen_sleep_disabled = false; bool s_screen_saver_active = false; +uint8_t s_saved_screen_brightness = DEVICE_MAX_BRIGHTNESS_LEVEL; uint32_t now_ms() { @@ -79,8 +82,7 @@ void load_timeout_if_needed_locked() void wake_display_locked() { - platform::esp::idf_common::bsp_runtime::set_display_brightness( - platform::esp::idf_common::bsp_runtime::default_awake_brightness_percent()); + platform::ui::device::set_screen_brightness(s_saved_screen_brightness); s_last_user_activity_ms = now_ms(); s_screen_sleeping = false; s_screen_saver_active = false; @@ -89,6 +91,7 @@ void wake_display_locked() void sleep_display_locked() { + s_saved_screen_brightness = platform::ui::device::screen_brightness(); platform::esp::idf_common::bsp_runtime::sleep_display(); s_screen_sleeping = true; s_screen_saver_active = false; diff --git a/platform/esp/idf_components/m5stack_tab5/m5stack_tab5.c b/platform/esp/idf_components/m5stack_tab5/m5stack_tab5.c index 4e0b3bae..6a5d0ebc 100644 --- a/platform/esp/idf_components/m5stack_tab5/m5stack_tab5.c +++ b/platform/esp/idf_components/m5stack_tab5/m5stack_tab5.c @@ -1545,7 +1545,7 @@ esp_err_t bsp_display_new_with_handles(const bsp_display_config_t* config, bsp_l .vsync_pulse_width = 4, .vsync_front_porch = 16, }, - //.flags.use_dma2d = true, // ??? 开启后需要等?previous draw 完成 + //.flags.use_dma2d = true, // Enable only after waiting for the previous draw to finish. }; st7703_vendor_config_t vendor_config = { diff --git a/platform/nrf52/arduino_common/include/ble/ble_manager.h b/platform/nrf52/arduino_common/include/ble/ble_manager.h index 9be0181c..a855ffb9 100644 --- a/platform/nrf52/arduino_common/include/ble/ble_manager.h +++ b/platform/nrf52/arduino_common/include/ble/ble_manager.h @@ -1,14 +1,31 @@ #pragma once -#include "app/app_facades.h" +#include "app/app_config.h" #include "chat/domain/chat_types.h" #include #include +namespace app +{ +class IAppBleFacade; +} + namespace ble { +class IBleRuntimeContext +{ + public: + virtual ~IBleRuntimeContext() = default; + virtual const app::AppConfig& bleConfig() const = 0; + virtual bool bleEnabled() const = 0; + virtual void bleEffectiveUserInfo(char* out_long, std::size_t long_len, + char* out_short, std::size_t short_len) const = 0; + virtual chat::NodeId bleSelfNodeId() const = 0; + virtual app::IAppBleFacade& bleAppFacade() = 0; +}; + struct BlePairingStatus { bool available = false; @@ -36,7 +53,7 @@ class BleService class BleManager { public: - explicit BleManager(app::IAppBleFacade& ctx); + explicit BleManager(IBleRuntimeContext& ctx); ~BleManager(); void begin(); @@ -50,7 +67,7 @@ class BleManager void restartService(chat::MeshProtocol protocol); std::string buildDeviceName(chat::MeshProtocol protocol) const; - app::IAppBleFacade& ctx_; + IBleRuntimeContext& ctx_; chat::MeshProtocol active_protocol_; std::unique_ptr service_; }; diff --git a/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h b/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h index 761c96c8..156ffcab 100644 --- a/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h +++ b/platform/nrf52/arduino_common/include/ble/meshtastic_ble.h @@ -1,7 +1,7 @@ #pragma once #include "app/app_facades.h" -#include "ble_manager.h" +#include "ble/ble_manager.h" #include "chat/ble/meshtastic_phone_session.h" #include "chat/domain/chat_types.h" #include "chat/ports/i_node_store.h" @@ -14,9 +14,9 @@ #include "meshtastic/mesh.pb.h" #include "meshtastic/module_config.pb.h" #include "meshtastic/telemetry.pb.h" +#include #include #include -#include #include #include @@ -27,29 +27,55 @@ class MeshtasticPhoneCore; class MeshtasticBleService final : public BleService, public chat::ChatService::IncomingTextObserver, + public chat::ChatService::OutgoingTextObserver, + public chat::ChatService::IncomingDataObserver, public MeshtasticPhoneTransport, public MeshtasticPhoneHooks { public: + struct Frame + { + size_t len = 0; + uint32_t from_num = 0; + std::array buf{}; + }; + MeshtasticBleService(app::IAppBleFacade& ctx, const std::string& device_name); ~MeshtasticBleService() override; void start() override; void stop() override; void update() override; + void onIncomingText(const chat::MeshIncomingText& msg) override; + void onOutgoingText(const chat::MeshIncomingText& msg) override; + void onIncomingData(const chat::MeshIncomingData& msg) override; + bool handleToRadio(const uint8_t* data, size_t len); + bool enqueueToRadio(const uint8_t* data, size_t len); bool popToPhone(MeshtasticBleFrame* out); + void handleToPhone(); + void flushPendingFromNumNotify(); + bool shouldBlockOnRead() const; + bool hasReadableFromRadio() const; + void markReadableFromRadioConsumed(); + bool popQueuedToPhoneFrame(Frame* out); + void beginReadWait(); + bool isReadWaiting() const; + void endReadWait(); + bool waitForReadableFromRadio(uint8_t max_tries, uint8_t delay_ms); void handleConnectEvent(uint16_t conn_handle); void handleDisconnectEvent(uint16_t conn_handle); + void handleFromNumCccdWrite(uint16_t conn_handle, uint16_t value); void handlePairPasskeyDisplay(uint16_t conn_handle, const uint8_t passkey[6], bool match_request); void handlePairComplete(uint16_t conn_handle, uint8_t auth_status); void handleSecured(uint16_t conn_handle); - bool getPairingStatus(BlePairingStatus* out) const override; + bool getPairingStatus(BlePairingStatus* out) const override; bool isBleConnected() const override; void notifyFromNum(uint32_t from_num) override; bool loadBluetoothConfig(meshtastic_Config_BluetoothConfig* out) const override; + void saveBluetoothConfig(const meshtastic_Config_BluetoothConfig& config) override; bool loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const override; bool loadModuleConfig(meshtastic_LocalModuleConfig* out) const override; void saveModuleConfig(const meshtastic_LocalModuleConfig& config) override; @@ -57,10 +83,24 @@ class MeshtasticBleService final : public BleService, bool pollMqttProxyToPhone(meshtastic_MqttClientProxyMessage* out) override; private: + struct PendingToRadioFrame + { + size_t len = 0; + uint8_t buf[meshtastic_ToRadio_size] = {}; + }; + + void processPendingToRadio(); + void processPendingPairingRequest(); + bool enqueueToPhoneFrame(const Frame& frame); + void clearToPhoneQueue(); + void prepareReadableFromRadio(); void syncMqttProxySettings(); + void markConfigSavePending(bool bluetooth_changed, bool module_changed); + void flushPendingConfigSaves(bool force = false); void applyBleSecurity(); void requestPairingIfNeeded(uint16_t conn_handle); uint32_t effectivePasskey() const; + void logDeferredBleEvents(); app::IAppBleFacade& ctx_; std::string device_name_; @@ -71,10 +111,64 @@ class MeshtasticBleService final : public BleService, ::BLECharacteristic log_radio_; bool active_ = false; bool connected_ = false; + bool from_num_notify_enabled_ = false; + uint16_t conn_handle_ = BLE_CONN_HANDLE_INVALID; meshtastic_Config_BluetoothConfig ble_config_ = meshtastic_Config_BluetoothConfig_init_zero; meshtastic_LocalModuleConfig module_config_ = meshtastic_LocalModuleConfig_init_zero; std::unique_ptr phone_session_; std::atomic pending_passkey_{0}; + + static constexpr uint8_t kPendingToRadioCapacity = 6; + PendingToRadioFrame pending_to_radio_[kPendingToRadioCapacity]{}; + volatile uint8_t pending_to_radio_head_ = 0; + volatile uint8_t pending_to_radio_tail_ = 0; + volatile uint8_t pending_to_radio_count_ = 0; + + volatile bool pairing_request_pending_ = false; + volatile uint16_t pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; + + std::atomic read_waiting_{false}; + + bool pending_to_phone_valid_ = false; + Frame pending_to_phone_{}; + MeshtasticBleFrame session_frame_scratch_{}; + + static constexpr uint8_t kToPhoneQueueDepth = 3; + Frame to_phone_queue_[kToPhoneQueueDepth]{}; + volatile uint8_t to_phone_head_ = 0; + volatile uint8_t to_phone_tail_ = 0; + volatile uint8_t to_phone_count_ = 0; + + bool from_radio_preloaded_valid_ = false; + Frame from_radio_preloaded_{}; + bool from_radio_consume_pending_ = false; + + bool pending_from_num_valid_ = false; + uint32_t pending_from_num_ = 0; + + volatile bool pending_connect_log_ = false; + volatile bool pending_disconnect_log_ = false; + volatile bool pending_from_num_cccd_log_ = false; + volatile bool pending_pair_complete_log_ = false; + volatile bool pending_secured_log_ = false; + + volatile uint16_t pending_connect_conn_handle_ = BLE_CONN_HANDLE_INVALID; + volatile uint16_t pending_disconnect_conn_handle_ = BLE_CONN_HANDLE_INVALID; + volatile uint16_t pending_from_num_cccd_conn_handle_ = BLE_CONN_HANDLE_INVALID; + volatile uint16_t pending_from_num_cccd_value_ = 0; + volatile uint16_t pending_pair_complete_conn_handle_ = BLE_CONN_HANDLE_INVALID; + volatile uint8_t pending_pair_complete_status_ = 0; + volatile uint16_t pending_secured_conn_handle_ = BLE_CONN_HANDLE_INVALID; + + volatile bool pending_from_radio_read_log_ = false; + volatile bool pending_from_radio_empty_log_ = false; + volatile uint32_t pending_from_radio_read_from_num_ = 0; + volatile uint16_t pending_from_radio_read_len_ = 0; + + bool bluetooth_config_save_pending_ = false; + bool module_config_save_pending_ = false; + uint32_t config_save_due_ms_ = 0; + uint32_t last_ble_activity_ms_ = 0; }; } // namespace ble diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h index a48435c5..8471e784 100644 --- a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h @@ -70,6 +70,7 @@ class MeshtasticRadioAdapter final : public ::chat::IMeshAdapter void handleRawPacket(const uint8_t* data, size_t size) override; void setLastRxStats(float rssi, float snr) override; void processSendQueue() override; + void flushDeferredPersistence(bool force = false); private: struct PacketHistoryEntry @@ -107,6 +108,32 @@ class MeshtasticRadioAdapter final : public ::chat::IMeshAdapter bool observe_only = false; }; + struct TxScratchBuffers + { + std::array app_data{}; + std::array aux_data{}; + std::array wire{}; + }; + + struct RxScratchBuffers + { + ::chat::meshtastic::PacketHeaderWire header{}; + std::array payload{}; + std::array plain{}; + meshtastic_Data decoded = meshtastic_Data_init_zero; + }; + + struct MqttScratchBuffers + { + std::array buffer{}; + std::array wire{}; + char channel_id[32] = {}; + char gateway_id[16] = {}; + meshtastic_Data decoded = meshtastic_Data_init_zero; + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + meshtastic_MqttClientProxyMessage proxy = meshtastic_MqttClientProxyMessage_init_zero; + }; + ::chat::runtime::EffectiveSelfIdentity buildEffectiveIdentity() const; bool transmitWire(const uint8_t* data, size_t size); bool transmitPreparedWire(uint8_t* data, size_t size, ::chat::ChannelId channel, @@ -169,7 +196,8 @@ class MeshtasticRadioAdapter final : public ::chat::IMeshAdapter bool initPkiKeys(); void loadPkiNodeKeys(); void savePkiNodeKey(::chat::NodeId node_id, const uint8_t* key, size_t key_len); - void savePkiKeysToPrefs(); + void markPkiKeysDirty(); + bool savePkiKeysToPrefs(); void touchPkiNodeKey(::chat::NodeId node_id); bool decryptPkiPayload(::chat::NodeId from, ::chat::MessageId packet_id, const uint8_t* cipher, size_t cipher_len, @@ -236,9 +264,14 @@ class MeshtasticRadioAdapter final : public ::chat::IMeshAdapter std::array pki_private_key_{}; std::map<::chat::NodeId, std::array> node_public_keys_; std::map<::chat::NodeId, uint32_t> node_key_last_seen_; + uint32_t pki_node_keys_save_due_ms_ = 0; + bool pki_node_keys_dirty_ = false; std::map<::chat::NodeId, ::chat::ChannelId> node_last_channel_; std::map<::chat::NodeId, uint32_t> nodeinfo_last_seen_ms_; uint32_t last_position_reply_ms_ = 0; + TxScratchBuffers tx_scratch_{}; + RxScratchBuffers rx_scratch_{}; + MqttScratchBuffers mqtt_scratch_{}; enum class KeyVerificationState : uint8_t { diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/node_store.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/node_store.h index 69900d0b..cafdefb5 100644 --- a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/node_store.h +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/node_store.h @@ -14,6 +14,7 @@ class NodeStore final : public ::chat::contacts::INodeStore NodeStore(); void begin() override; + void applyUpdate(uint32_t node_id, const ::chat::contacts::NodeUpdate& update) override; void upsert(uint32_t node_id, const char* short_name, const char* long_name, uint32_t now_secs, float snr = 0.0f, float rssi = 0.0f, uint8_t protocol = 0, uint8_t role = ::chat::contacts::kNodeRoleUnknown, uint8_t hops_away = 0xFF, @@ -25,6 +26,7 @@ class NodeStore final : public ::chat::contacts::INodeStore bool remove(uint32_t node_id) override; const std::vector<::chat::contacts::NodeEntry>& getEntries() const override; void clear() override; + bool flush() override; void setProtectedNodeChecker(std::function checker); private: diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h index ab238c04..39000208 100644 --- a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h @@ -26,6 +26,7 @@ class InternalFsStore final : public ::chat::IChatStore void clearConversation(const ::chat::ConversationId& conv) override; void clearAll() override; bool updateMessageStatus(::chat::MessageId msg_id, ::chat::MessageStatus status) override; + void flush() override; private: struct StoredMessageEntry @@ -107,6 +108,8 @@ class InternalFsStore final : public ::chat::IChatStore bool ensureFs() const; bool loadFromFs(); bool saveToFs() const; + void markDirty(); + void maybeSave(bool force = false); void evictOldestMessage(); ConversationStorage& getConversationStorage(const ::chat::ConversationId& conv); const ConversationStorage& getConversationStorage(const ::chat::ConversationId& conv) const; @@ -115,6 +118,13 @@ class InternalFsStore final : public ::chat::IChatStore std::map<::chat::ConversationId, ConversationStorage> conversations_; uint32_t next_sequence_ = 1; size_t total_message_count_ = 0; + mutable uint32_t last_save_ms_ = 0; + mutable uint32_t dirty_since_ms_ = 0; + mutable size_t pending_write_count_ = 0; + mutable bool dirty_ = false; + + static constexpr uint32_t kSaveIntervalMs = 1500; + static constexpr size_t kMaxPendingWrites = 4; }; } // namespace platform::nrf52::arduino_common::chat::infra::store diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/internal_fs_utils.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/internal_fs_utils.h new file mode 100644 index 00000000..285d26df --- /dev/null +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/internal_fs_utils.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include + +namespace platform::nrf52::arduino_common::internal_fs +{ + +using Adafruit_LittleFS_Namespace::File; + +bool ensureMounted(bool allow_format_recovery = false, const char* log_tag = nullptr); +void removeIfExists(const char* path); +bool openForOverwrite(const char* path, + File* out, + bool allow_format_recovery = false, + const char* log_tag = nullptr); +bool rewindForOverwrite(File& file); +bool truncateAfterWrite(File& file, uint32_t final_size); +uint32_t accumulateBytes(File dir); + +} // namespace platform::nrf52::arduino_common::internal_fs diff --git a/platform/nrf52/arduino_common/src/ble/ble_manager.cpp b/platform/nrf52/arduino_common/src/ble/ble_manager.cpp index 05f6f8fd..082dbb48 100644 --- a/platform/nrf52/arduino_common/src/ble/ble_manager.cpp +++ b/platform/nrf52/arduino_common/src/ble/ble_manager.cpp @@ -2,20 +2,43 @@ #include "../../include/ble/meshcore_ble.h" #include "../../include/ble/meshtastic_ble.h" -#include "app/app_config.h" #include "chat/infra/mesh_protocol_utils.h" #include "chat/runtime/self_identity_policy.h" #include +#include #include #include namespace ble { +namespace +{ -BleManager::BleManager(app::IAppBleFacade& ctx) +bool usbSerialWritable(std::size_t len) +{ + return static_cast(Serial) && Serial.dtr() != 0 && Serial.availableForWrite() >= static_cast(len); +} + +void bleManagerLog(const char* fmt, ...) +{ + char buffer[160] = {}; + va_list args; + va_start(args, fmt); + std::vsnprintf(buffer, sizeof(buffer), fmt, args); + va_end(args); + if (usbSerialWritable(std::strlen(buffer) + 2U)) + { + Serial.println(buffer); + } + Serial2.println(buffer); +} + +} // namespace + +BleManager::BleManager(IBleRuntimeContext& ctx) : ctx_(ctx), - active_protocol_(ctx.getConfig().mesh_protocol) + active_protocol_(ctx.bleConfig().mesh_protocol) { } @@ -30,6 +53,9 @@ BleManager::~BleManager() void BleManager::begin() { + bleManagerLog("[BLE][nrf52] begin enabled=%u proto=%u", + ctx_.bleEnabled() ? 1U : 0U, + static_cast(ctx_.bleConfig().mesh_protocol)); setEnabled(true); } @@ -39,13 +65,16 @@ void BleManager::setEnabled(bool enabled) { if (!service_) { - restartService(ctx_.getConfig().mesh_protocol); + bleManagerLog("[BLE][nrf52] setEnabled on proto=%u", + static_cast(ctx_.bleConfig().mesh_protocol)); + restartService(ctx_.bleConfig().mesh_protocol); } } else { if (service_) { + bleManagerLog("[BLE][nrf52] setEnabled off"); service_->stop(); service_.reset(); } @@ -54,12 +83,12 @@ void BleManager::setEnabled(bool enabled) bool BleManager::isEnabled() const { - return ctx_.isBleEnabled(); + return ctx_.bleEnabled(); } void BleManager::update() { - const auto current_protocol = ctx_.getConfig().mesh_protocol; + const auto current_protocol = ctx_.bleConfig().mesh_protocol; if (current_protocol != active_protocol_) { applyProtocol(current_protocol); @@ -104,26 +133,26 @@ void BleManager::restartService(chat::MeshProtocol protocol) switch (active_protocol_) { case chat::MeshProtocol::MeshCore: - service_ = std::unique_ptr(new MeshCoreBleService(ctx_, device_name)); + service_ = std::unique_ptr(new MeshCoreBleService(ctx_.bleAppFacade(), device_name)); break; case chat::MeshProtocol::Meshtastic: default: - service_ = std::unique_ptr(new MeshtasticBleService(ctx_, device_name)); + service_ = std::unique_ptr(new MeshtasticBleService(ctx_.bleAppFacade(), device_name)); break; } if (service_) { service_->start(); - Serial2.printf("[BLE][nrf52] protocol=%s name=%s service=started\n", - protocol_name, - device_name.c_str()); + bleManagerLog("[BLE][nrf52] protocol=%s name=%s service=started", + protocol_name, + device_name.c_str()); } else { - Serial2.printf("[BLE][nrf52] protocol=%s name=%s service=create_failed\n", - protocol_name, - device_name.c_str()); + bleManagerLog("[BLE][nrf52] protocol=%s name=%s service=create_failed", + protocol_name, + device_name.c_str()); } } @@ -131,10 +160,10 @@ std::string BleManager::buildDeviceName(chat::MeshProtocol protocol) const { char long_name[32] = {}; char short_name[16] = {}; - ctx_.getEffectiveUserInfo(long_name, sizeof(long_name), short_name, sizeof(short_name)); + ctx_.bleEffectiveUserInfo(long_name, sizeof(long_name), short_name, sizeof(short_name)); chat::runtime::EffectiveSelfIdentity identity{}; - identity.node_id = ctx_.getSelfNodeId(); + identity.node_id = ctx_.bleSelfNodeId(); std::strncpy(identity.long_name, long_name, sizeof(identity.long_name) - 1); identity.long_name[sizeof(identity.long_name) - 1] = '\0'; std::strncpy(identity.short_name, short_name, sizeof(identity.short_name) - 1); diff --git a/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp b/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp index 958ca7e1..9023c889 100644 --- a/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp +++ b/platform/nrf52/arduino_common/src/ble/meshtastic_ble.cpp @@ -2,19 +2,54 @@ #include "app/app_config.h" #include "ble/ble_uuids.h" +#if defined(GAT562_MESH_EVB_PRO) +#include "boards/gat562_mesh_evb_pro/settings_store.h" +#endif +#include "chat/ble/meshtastic_defaults.h" #include "chat/infra/meshtastic/mt_region.h" #include "platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h" +#if !defined(GAT562_MESH_EVB_PRO) +#include "platform/ui/settings_store.h" +#endif #include +#include #include #include +#if !defined(GAT562_MESH_EVB_PRO) +#include +#endif namespace ble { namespace { -constexpr const char* kDefaultMqttRoot = "msh"; constexpr uint32_t kDefaultBleFixedPin = 654321; +constexpr uint32_t kConfigSaveDebounceMs = 1500UL; +#if !defined(GAT562_MESH_EVB_PRO) +constexpr const char* kBleSettingsNamespace = "ble_meshtastic"; +constexpr const char* kBluetoothConfigKey = "bt_cfg"; +constexpr const char* kModuleConfigKey = "mod_cfg"; +#endif + +bool usbSerialWritable(std::size_t len) +{ + return static_cast(Serial) && Serial.dtr() != 0 && Serial.availableForWrite() >= static_cast(len); +} + +void bleLogBoth(const char* fmt, ...) +{ + char buffer[192] = {}; + va_list args; + va_start(args, fmt); + std::vsnprintf(buffer, sizeof(buffer), fmt, args); + va_end(args); + if (usbSerialWritable(std::strlen(buffer) + 2U)) + { + Serial.println(buffer); + } + Serial2.println(buffer); +} platform::nrf52::arduino_common::chat::meshtastic::MeshtasticRadioAdapter* getMeshtasticBackend(app::IAppBleFacade& ctx) { @@ -44,6 +79,201 @@ void copyBounded(char* dst, size_t dst_len, const char* src) dst[dst_len - 1] = '\0'; } +void repairLegacyMqttConfig(meshtastic_LocalModuleConfig* config) +{ + if (!config) + { + return; + } + + config->has_mqtt = true; + config->mqtt.address[sizeof(config->mqtt.address) - 1] = '\0'; + config->mqtt.username[sizeof(config->mqtt.username) - 1] = '\0'; + config->mqtt.password[sizeof(config->mqtt.password) - 1] = '\0'; + config->mqtt.root[sizeof(config->mqtt.root) - 1] = '\0'; + if (config->mqtt.address[0] == '\0') + { + copyBounded(config->mqtt.address, sizeof(config->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); + } + if (config->mqtt.username[0] == '\0') + { + copyBounded(config->mqtt.username, sizeof(config->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); + } + if (config->mqtt.password[0] == '\0') + { + copyBounded(config->mqtt.password, sizeof(config->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); + } + if (config->mqtt.root[0] == '\0') + { + copyBounded(config->mqtt.root, sizeof(config->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); + } + if (!config->mqtt.has_map_report_settings) + { + config->mqtt.has_map_report_settings = true; + config->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; + config->mqtt.map_report_settings.position_precision = 0; + config->mqtt.map_report_settings.should_report_location = false; + } +} + +void initDefaultModuleConfig(app::IAppBleFacade& ctx, meshtastic_LocalModuleConfig* config) +{ + if (!config) + { + return; + } + + meshtastic_LocalModuleConfig zero = meshtastic_LocalModuleConfig_init_zero; + *config = zero; + config->version = meshtastic_defaults::kModuleConfigVersion; + config->has_mqtt = true; + config->has_serial = true; + config->has_external_notification = true; + config->has_store_forward = true; + config->has_range_test = true; + config->has_telemetry = true; + config->has_canned_message = true; + config->has_audio = true; + config->has_remote_hardware = true; + config->has_neighbor_info = true; + config->has_ambient_lighting = true; + config->has_detection_sensor = true; + config->has_paxcounter = true; + + config->mqtt.enabled = false; + config->mqtt.proxy_to_client_enabled = false; + copyBounded(config->mqtt.address, sizeof(config->mqtt.address), meshtastic_defaults::kDefaultMqttAddress); + copyBounded(config->mqtt.username, sizeof(config->mqtt.username), meshtastic_defaults::kDefaultMqttUsername); + copyBounded(config->mqtt.password, sizeof(config->mqtt.password), meshtastic_defaults::kDefaultMqttPassword); + copyBounded(config->mqtt.root, sizeof(config->mqtt.root), meshtastic_defaults::kDefaultMqttRoot); + config->mqtt.encryption_enabled = meshtastic_defaults::kDefaultMqttEncryptionEnabled; + config->mqtt.tls_enabled = meshtastic_defaults::kDefaultMqttTlsEnabled; + config->mqtt.has_map_report_settings = true; + config->mqtt.map_report_settings.publish_interval_secs = meshtastic_defaults::kDefaultMapPublishIntervalSecs; + config->mqtt.map_report_settings.position_precision = 0; + config->mqtt.map_report_settings.should_report_location = false; + + config->telemetry.device_update_interval = 3600; + config->telemetry.device_telemetry_enabled = true; + config->telemetry.environment_update_interval = 0; + config->telemetry.environment_measurement_enabled = false; + config->telemetry.power_update_interval = 0; + config->telemetry.health_update_interval = 0; + config->telemetry.air_quality_interval = 0; + + config->neighbor_info.enabled = false; + config->neighbor_info.update_interval = 0; + config->neighbor_info.transmit_over_lora = false; + + config->detection_sensor.enabled = false; + config->ambient_lighting.current = meshtastic_defaults::kDefaultAmbientCurrent; + const uint32_t self_node = ctx.getSelfNodeId(); + config->ambient_lighting.red = (self_node >> 16) & 0xFFU; + config->ambient_lighting.green = (self_node >> 8) & 0xFFU; + config->ambient_lighting.blue = self_node & 0xFFU; +} + +struct PersistedBleState +{ + bool has_bluetooth = false; + bool has_module = false; + meshtastic_Config_BluetoothConfig bluetooth = meshtastic_Config_BluetoothConfig_init_zero; + meshtastic_LocalModuleConfig module = meshtastic_LocalModuleConfig_init_zero; +}; + +#if !defined(GAT562_MESH_EVB_PRO) +template +bool loadBlobConfigFromUiStore(const char* key, T* out) +{ + if (!out) + { + return false; + } + + std::vector blob; + if (!platform::ui::settings_store::get_blob(kBleSettingsNamespace, key, blob)) + { + return false; + } + if (blob.size() != sizeof(T)) + { + bleLogBoth("[BLE][nrf52][mt] load cfg size mismatch key=%s got=%u expected=%u", + key ? key : "?", + static_cast(blob.size()), + static_cast(sizeof(T))); + return false; + } + + std::memcpy(out, blob.data(), sizeof(T)); + return true; +} + +template +bool saveBlobConfigToUiStore(const char* key, const T& value) +{ + const bool ok = platform::ui::settings_store::put_blob(kBleSettingsNamespace, key, &value, sizeof(T)); + if (!ok) + { + bleLogBoth("[BLE][nrf52][mt] save cfg failed key=%s size=%u", + key ? key : "?", + static_cast(sizeof(T))); + } + return ok; +} +#endif + +bool loadPersistedBleState(PersistedBleState* out) +{ + if (!out) + { + return false; + } + + *out = PersistedBleState{}; + +#if defined(GAT562_MESH_EVB_PRO) + if (::boards::gat562_mesh_evb_pro::settings_store::loadMeshtasticBleState(&out->bluetooth, &out->module)) + { + out->has_bluetooth = true; + out->has_module = true; + return true; + } + return false; +#else + out->has_bluetooth = loadBlobConfigFromUiStore(kBluetoothConfigKey, &out->bluetooth); + out->has_module = loadBlobConfigFromUiStore(kModuleConfigKey, &out->module); + if (out->has_bluetooth || out->has_module) + { + bleLogBoth("[BLE][nrf52][mt] legacy cfg store fallback bt=%u mod=%u", + out->has_bluetooth ? 1U : 0U, + out->has_module ? 1U : 0U); + } + return out->has_bluetooth || out->has_module; +#endif +} + +bool savePersistedBleState(const meshtastic_Config_BluetoothConfig& bluetooth, + const meshtastic_LocalModuleConfig& module) +{ +#if defined(GAT562_MESH_EVB_PRO) + return ::boards::gat562_mesh_evb_pro::settings_store::saveMeshtasticBleState(bluetooth, module); +#else + const bool bluetooth_ok = saveBlobConfigToUiStore(kBluetoothConfigKey, bluetooth); + const bool module_ok = saveBlobConfigToUiStore(kModuleConfigKey, module); + return bluetooth_ok && module_ok; +#endif +} + +bool isValidBluetoothMode(uint8_t mode) +{ + return mode <= static_cast(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN); +} + +bool isValidBlePin(uint32_t pin) +{ + return pin == 0 || (pin >= 100000 && pin <= 999999); +} + std::string channelDisplayName(const app::IAppBleFacade& ctx, uint8_t idx) { const auto& cfg = ctx.getConfig(); @@ -124,16 +354,19 @@ void onSecured(uint16_t conn_handle) void prepareBluefruit(const std::string& device_name) { + bleLogBoth("[BLE][nrf52][mt] bluefruit begin name=%s", device_name.c_str()); Bluefruit.autoConnLed(false); Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); Bluefruit.begin(); Bluefruit.setName(device_name.c_str()); Bluefruit.Periph.setConnectCallback(onBleConnect); Bluefruit.Periph.setDisconnectCallback(onBleDisconnect); + bleLogBoth("[BLE][nrf52][mt] bluefruit ready"); } void startAdvertising(::BLEService& service) { + (void)service; Bluefruit.Advertising.stop(); Bluefruit.Advertising.clearData(); Bluefruit.ScanResponse.clearData(); @@ -145,6 +378,8 @@ void startAdvertising(::BLEService& service) Bluefruit.Advertising.setInterval(32, 668); Bluefruit.Advertising.setFastTimeout(30); Bluefruit.Advertising.start(0); + bleLogBoth("[BLE][nrf52][mt] advertising started running=%u", + Bluefruit.Advertising.isRunning() ? 1U : 0U); } void disconnectAll() @@ -171,7 +406,15 @@ void onToRadioWrite(uint16_t, BLECharacteristic*, uint8_t* data, uint16_t len) { return; } - (void)s_active_service->handleToRadio(data, len); + (void)s_active_service->enqueueToRadio(data, len); +} + +void onFromNumCccdWrite(uint16_t conn_handle, BLECharacteristic*, uint16_t value) +{ + if (s_active_service) + { + s_active_service->handleFromNumCccdWrite(conn_handle, value); + } } void onFromRadioAuthorize(uint16_t conn_handle, BLECharacteristic* chr, ble_gatts_evt_read_t* request) @@ -184,15 +427,29 @@ void onFromRadioAuthorize(uint16_t conn_handle, BLECharacteristic* chr, ble_gatt if (request->offset == 0) { - MeshtasticBleFrame frame{}; - if (s_active_service && s_active_service->popToPhone(&frame)) + if (s_active_service && s_active_service->hasReadableFromRadio()) { - chr->write(frame.buf, frame.len); + s_active_service->beginReadWait(); } else { - uint8_t empty = 0; - chr->write(&empty, 0); + if (s_active_service) + { + if (s_active_service->shouldBlockOnRead()) + { + s_active_service->waitForReadableFromRadio(20, 1); + } + + if (s_active_service->hasReadableFromRadio()) + { + s_active_service->beginReadWait(); + } + else + { + s_active_service->markReadableFromRadioConsumed(); + s_active_service->endReadWait(); + } + } } } @@ -208,20 +465,60 @@ MeshtasticBleService::MeshtasticBleService(app::IAppBleFacade& ctx, const std::s to_radio_(::BLEUuid(TORADIO_UUID)), from_radio_(::BLEUuid(FROMRADIO_UUID)), from_num_(::BLEUuid(FROMNUM_UUID)), - log_radio_(::BLEUuid(LOGRADIO_UUID)), - phone_session_(new MeshtasticPhoneSession(ctx, *this, this)) + log_radio_(::BLEUuid(LOGRADIO_UUID)) { ble_config_ = meshtastic_Config_BluetoothConfig_init_zero; ble_config_.enabled = ctx_.isBleEnabled(); ble_config_.mode = meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN; ble_config_.fixed_pin = 0; - std::memset(&module_config_, 0, sizeof(module_config_)); - module_config_.has_mqtt = true; - module_config_.mqtt.enabled = false; - module_config_.mqtt.proxy_to_client_enabled = false; - module_config_.mqtt.encryption_enabled = true; - copyBounded(module_config_.mqtt.root, sizeof(module_config_.mqtt.root), kDefaultMqttRoot); + PersistedBleState persisted{}; + const bool persisted_ok = loadPersistedBleState(&persisted); + + bleLogBoth("[BLE][nrf52][mt] persisted load ok=%u has_bt=%u has_mod=%u", + persisted_ok ? 1U : 0U, + persisted.has_bluetooth ? 1U : 0U, + persisted.has_module ? 1U : 0U); + +#if defined(GAT562_MESH_EVB_PRO) + bleLogBoth("[BLE][nrf52][mt] settings_store load=%s save=%s", + ::boards::gat562_mesh_evb_pro::settings_store::statusLabel( + ::boards::gat562_mesh_evb_pro::settings_store::lastLoadStatus()), + ::boards::gat562_mesh_evb_pro::settings_store::statusLabel( + ::boards::gat562_mesh_evb_pro::settings_store::lastSaveStatus())); +#endif + + if (persisted.has_bluetooth && + isValidBluetoothMode(static_cast(persisted.bluetooth.mode)) && + isValidBlePin(persisted.bluetooth.fixed_pin)) + { + ble_config_.mode = persisted.bluetooth.mode; + ble_config_.fixed_pin = persisted.bluetooth.fixed_pin; + bleLogBoth("[BLE][nrf52][mt] loaded bluetooth cfg mode=%u pin=%06lu", + static_cast(ble_config_.mode), + static_cast(ble_config_.fixed_pin)); + } + if (ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) + { + ble_config_.fixed_pin = 0; + } + + initDefaultModuleConfig(ctx_, &module_config_); + if (persisted.has_module) + { + module_config_ = persisted.module; + if (module_config_.version == 0) + { + module_config_.version = meshtastic_defaults::kModuleConfigVersion; + } + repairLegacyMqttConfig(&module_config_); + bleLogBoth("[BLE][nrf52][mt] loaded module cfg mqtt enabled=%u proxy=%u root=%s", + module_config_.has_mqtt && module_config_.mqtt.enabled ? 1U : 0U, + module_config_.has_mqtt && module_config_.mqtt.proxy_to_client_enabled ? 1U : 0U, + module_config_.mqtt.root); + } + + phone_session_.reset(new MeshtasticPhoneSession(ctx, *this, this)); } MeshtasticBleService::~MeshtasticBleService() @@ -232,65 +529,107 @@ MeshtasticBleService::~MeshtasticBleService() void MeshtasticBleService::start() { s_active_service = this; + conn_handle_ = BLE_CONN_HANDLE_INVALID; + from_num_notify_enabled_ = false; + pending_to_radio_head_ = 0; + pending_to_radio_tail_ = 0; + pending_to_radio_count_ = 0; + clearToPhoneQueue(); + pending_from_num_valid_ = false; + pending_from_num_ = 0; + pending_connect_log_ = false; + pending_disconnect_log_ = false; + pending_from_num_cccd_log_ = false; + pending_pair_complete_log_ = false; + pending_secured_log_ = false; + pending_from_radio_read_log_ = false; + pending_from_radio_empty_log_ = false; + pairing_request_pending_ = false; + pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; + last_ble_activity_ms_ = millis(); + prepareBluefruit(device_name_); applyBleSecurity(); service_.begin(); + bleLogBoth("[BLE][nrf52][mt] service begin"); to_radio_.setProperties(CHR_PROPS_WRITE); - to_radio_.setPermission(ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN ? SECMODE_OPEN - : SECMODE_ENC_WITH_MITM, - ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN ? SECMODE_OPEN - : SECMODE_ENC_WITH_MITM); + to_radio_.setPermission(SECMODE_OPEN, SECMODE_OPEN); to_radio_.setFixedLen(0); to_radio_.setMaxLen(meshtastic_ToRadio_size); to_radio_.setWriteCallback(onToRadioWrite, false); to_radio_.begin(); from_radio_.setProperties(CHR_PROPS_READ); - from_radio_.setPermission(ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN ? SECMODE_OPEN - : SECMODE_ENC_WITH_MITM, - SECMODE_NO_ACCESS); + from_radio_.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); from_radio_.setFixedLen(0); from_radio_.setMaxLen(meshtastic_FromRadio_size); from_radio_.setReadAuthorizeCallback(onFromRadioAuthorize, false); from_radio_.begin(); + { + uint8_t empty = 0; + from_radio_.write(&empty, 0); + } from_num_.setProperties(CHR_PROPS_NOTIFY | CHR_PROPS_READ); - from_num_.setPermission(ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN ? SECMODE_OPEN - : SECMODE_ENC_WITH_MITM, - SECMODE_NO_ACCESS); + from_num_.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); from_num_.setFixedLen(4); from_num_.write32(0); + from_num_.setCccdWriteCallback(onFromNumCccdWrite, false); from_num_.begin(); log_radio_.setProperties(CHR_PROPS_NOTIFY | CHR_PROPS_READ); - log_radio_.setPermission(ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN ? SECMODE_OPEN - : SECMODE_ENC_WITH_MITM, - SECMODE_NO_ACCESS); + log_radio_.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); log_radio_.setFixedLen(0); log_radio_.setMaxLen(96); log_radio_.begin(); + bleLogBoth("[BLE][nrf52][mt] chars ready"); ctx_.getChatService().addIncomingTextObserver(this); + ctx_.getChatService().addOutgoingTextObserver(this); + ctx_.getChatService().addIncomingDataObserver(this); + startAdvertising(service_); active_ = true; pending_passkey_.store(0); syncMqttProxySettings(); + bleLogBoth("[BLE][nrf52][mt] service active"); } void MeshtasticBleService::stop() { ctx_.getChatService().removeIncomingTextObserver(this); + ctx_.getChatService().removeOutgoingTextObserver(this); + ctx_.getChatService().removeIncomingDataObserver(this); + disconnectAll(); Bluefruit.Advertising.stop(); + flushPendingConfigSaves(true); if (phone_session_) { phone_session_->close(); } active_ = false; connected_ = false; + from_num_notify_enabled_ = false; + conn_handle_ = BLE_CONN_HANDLE_INVALID; pending_passkey_.store(0); + pending_to_radio_head_ = 0; + pending_to_radio_tail_ = 0; + pending_to_radio_count_ = 0; + clearToPhoneQueue(); + pending_from_num_valid_ = false; + pending_from_num_ = 0; + pending_connect_log_ = false; + pending_disconnect_log_ = false; + pending_from_num_cccd_log_ = false; + pending_pair_complete_log_ = false; + pending_secured_log_ = false; + pending_from_radio_read_log_ = false; + pending_from_radio_empty_log_ = false; + pairing_request_pending_ = false; + pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; if (s_active_service == this) { s_active_service = nullptr; @@ -311,9 +650,22 @@ void MeshtasticBleService::update() phone_session_->pumpIncomingAppData(); } + processPendingPairingRequest(); + processPendingToRadio(); + if (from_radio_consume_pending_) + { + markReadableFromRadioConsumed(); + } + handleToPhone(); + prepareReadableFromRadio(); + flushPendingFromNumNotify(); + logDeferredBleEvents(); + flushPendingConfigSaves(false); + if (!Bluefruit.connected() && !Bluefruit.Advertising.isRunning()) { Bluefruit.Advertising.start(0); + bleLogBoth("[BLE][nrf52][mt] advertising restarted"); } } @@ -322,61 +674,428 @@ void MeshtasticBleService::onIncomingText(const chat::MeshIncomingText& msg) if (phone_session_) { phone_session_->onIncomingText(msg); + if (phone_session_->isSendingPackets()) + { + notifyFromNum(0); + } + } +} + +void MeshtasticBleService::onOutgoingText(const chat::MeshIncomingText& msg) +{ + if (phone_session_) + { + Serial2.printf("[BLE][nrf52][mt] local text mirror id=%08lX from=%08lX to=%08lX len=%u\n", + static_cast(msg.msg_id), + static_cast(msg.from), + static_cast(msg.to), + static_cast(msg.text.size())); + phone_session_->onIncomingText(msg); + if (phone_session_->isSendingPackets()) + { + notifyFromNum(0); + } + } +} + +void MeshtasticBleService::onIncomingData(const chat::MeshIncomingData& msg) +{ + if (phone_session_) + { + phone_session_->onIncomingData(msg); + if (phone_session_->isSendingPackets()) + { + notifyFromNum(0); + } } } bool MeshtasticBleService::handleToRadio(const uint8_t* data, size_t len) { + last_ble_activity_ms_ = millis(); Serial2.printf("[BLE][nrf52][mt] handleToRadio len=%u connected=%u\n", static_cast(len), connected_ ? 1U : 0U); return phone_session_ ? phone_session_->handleToRadio(data, len) : false; } +void MeshtasticBleService::handleToPhone() +{ + if (!phone_session_) + { + return; + } + + const bool waiting_for_read = read_waiting_.load(); + const bool in_send_packets = phone_session_->isSendingPackets(); + const bool config_flow_active = phone_session_->isConfigFlowActive(); + const bool can_prepare = connected_ && (!in_send_packets || config_flow_active); + + if (config_flow_active && !from_num_notify_enabled_) + { + return; + } + + if (config_flow_active && (from_radio_preloaded_valid_ || to_phone_count_ > 0 || pending_to_phone_valid_)) + { + return; + } + + Frame* frame = &pending_to_phone_; + if (pending_to_phone_valid_) + { + } + else + { + if (!waiting_for_read && !can_prepare) + { + return; + } + if (to_phone_count_ >= kToPhoneQueueDepth) + { + return; + } + + *frame = Frame{}; + session_frame_scratch_ = MeshtasticBleFrame{}; + if (!phone_session_->popToPhone(&session_frame_scratch_)) + { + if (waiting_for_read && in_send_packets) + { + read_waiting_.store(false); + } + return; + } + + if (session_frame_scratch_.len == 0 || session_frame_scratch_.len > frame->buf.size()) + { + bleLogBoth("[BLE][nrf52][mt] drop oversize to_phone frame from_num=%08lX len=%u max=%u", + static_cast(session_frame_scratch_.from_num), + static_cast(session_frame_scratch_.len), + static_cast(frame->buf.size())); + if (waiting_for_read && in_send_packets) + { + read_waiting_.store(false); + } + return; + } + + frame->len = session_frame_scratch_.len; + frame->from_num = session_frame_scratch_.from_num; + std::memcpy(frame->buf.data(), session_frame_scratch_.buf, session_frame_scratch_.len); + } + + if (enqueueToPhoneFrame(*frame)) + { + pending_to_phone_valid_ = false; + Serial2.printf("[BLE][nrf52][mt] to_phone enqueue from_num=%08lX len=%u q=%u\n", + static_cast(frame->from_num), + static_cast(frame->len), + static_cast(to_phone_count_)); + if (!waiting_for_read && (in_send_packets || config_flow_active)) + { + notifyFromNum(frame->from_num); + } + else if (can_prepare && !config_flow_active && to_phone_count_ < kToPhoneQueueDepth) + { + handleToPhone(); + } + } + else + { + pending_to_phone_ = *frame; + pending_to_phone_valid_ = true; + Serial2.printf("[BLE][nrf52][mt] to_phone defer from_num=%08lX len=%u\n", + static_cast(frame->from_num), + static_cast(frame->len)); + } +} + +bool MeshtasticBleService::enqueueToRadio(const uint8_t* data, size_t len) +{ + if (!data || len == 0 || len > meshtastic_ToRadio_size) + { + return false; + } + + noInterrupts(); + if (pending_to_radio_count_ >= kPendingToRadioCapacity) + { + interrupts(); + Serial2.printf("[BLE][nrf52][mt] to_radio queue full len=%u\n", static_cast(len)); + return false; + } + + PendingToRadioFrame& frame = pending_to_radio_[pending_to_radio_tail_]; + std::memcpy(frame.buf, data, len); + frame.len = len; + pending_to_radio_tail_ = static_cast((pending_to_radio_tail_ + 1U) % kPendingToRadioCapacity); + ++pending_to_radio_count_; + interrupts(); + return true; +} + +void MeshtasticBleService::processPendingToRadio() +{ + PendingToRadioFrame frame{}; + while (true) + { + noInterrupts(); + if (pending_to_radio_count_ == 0) + { + interrupts(); + return; + } + + frame = pending_to_radio_[pending_to_radio_head_]; + pending_to_radio_head_ = static_cast((pending_to_radio_head_ + 1U) % kPendingToRadioCapacity); + --pending_to_radio_count_; + interrupts(); + + (void)handleToRadio(frame.buf, frame.len); + } +} + +void MeshtasticBleService::processPendingPairingRequest() +{ + if (!pairing_request_pending_) + { + return; + } + + const uint16_t conn_handle = pending_pairing_conn_handle_; + pairing_request_pending_ = false; + pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; + requestPairingIfNeeded(conn_handle); +} + bool MeshtasticBleService::popToPhone(MeshtasticBleFrame* out) { return phone_session_ ? phone_session_->popToPhone(out) : false; } +bool MeshtasticBleService::shouldBlockOnRead() const +{ + if (!connected_ || !phone_session_) + { + return false; + } + return phone_session_->isSendingPackets() || phone_session_->isConfigFlowActive(); +} + +void MeshtasticBleService::beginReadWait() +{ + read_waiting_.store(true); + if (from_radio_preloaded_valid_) + { + from_radio_consume_pending_ = true; + } +} + +bool MeshtasticBleService::isReadWaiting() const +{ + return read_waiting_.load(); +} + +void MeshtasticBleService::endReadWait() +{ + read_waiting_.store(false); +} + +bool MeshtasticBleService::waitForReadableFromRadio(uint8_t max_tries, uint8_t delay_ms) +{ + beginReadWait(); + uint8_t tries = 0; + while (!hasReadableFromRadio() && isReadWaiting() && tries < max_tries) + { + handleToPhone(); + prepareReadableFromRadio(); + if (hasReadableFromRadio()) + { + break; + } + delay(delay_ms); + ++tries; + } + return hasReadableFromRadio(); +} + +bool MeshtasticBleService::enqueueToPhoneFrame(const Frame& frame) +{ + if (frame.len == 0 || frame.len > meshtastic_FromRadio_size) + { + return false; + } + + noInterrupts(); + if (to_phone_count_ >= kToPhoneQueueDepth) + { + interrupts(); + return false; + } + + to_phone_queue_[to_phone_tail_] = frame; + to_phone_tail_ = static_cast((to_phone_tail_ + 1U) % kToPhoneQueueDepth); + ++to_phone_count_; + interrupts(); + return true; +} + +bool MeshtasticBleService::popQueuedToPhoneFrame(Frame* out) +{ + if (!out) + { + return false; + } + + noInterrupts(); + if (to_phone_count_ == 0) + { + interrupts(); + return false; + } + + *out = to_phone_queue_[to_phone_head_]; + to_phone_head_ = static_cast((to_phone_head_ + 1U) % kToPhoneQueueDepth); + --to_phone_count_; + interrupts(); + return true; +} + +void MeshtasticBleService::clearToPhoneQueue() +{ + read_waiting_.store(false); + pending_to_phone_valid_ = false; + pending_to_phone_ = Frame{}; + from_radio_preloaded_valid_ = false; + from_radio_preloaded_ = Frame{}; + from_radio_consume_pending_ = false; + noInterrupts(); + to_phone_head_ = 0; + to_phone_tail_ = 0; + to_phone_count_ = 0; + interrupts(); +} + +void MeshtasticBleService::prepareReadableFromRadio() +{ + if (from_radio_preloaded_valid_) + { + return; + } + + Frame frame{}; + if (!popQueuedToPhoneFrame(&frame)) + { + return; + } + + from_radio_.write(frame.buf.data(), frame.len); + from_radio_preloaded_ = frame; + from_radio_preloaded_valid_ = true; + bleLogBoth("[BLE][nrf52][mt][flow] preload from_num=%08lX len=%u q=%u", + static_cast(frame.from_num), + static_cast(frame.len), + static_cast(to_phone_count_)); +} + +bool MeshtasticBleService::hasReadableFromRadio() const +{ + return from_radio_preloaded_valid_; +} + +void MeshtasticBleService::markReadableFromRadioConsumed() +{ + from_radio_consume_pending_ = false; + + if (!from_radio_preloaded_valid_) + { + pending_from_radio_empty_log_ = true; + return; + } + + pending_from_radio_read_len_ = static_cast(from_radio_preloaded_.len); + pending_from_radio_read_from_num_ = from_radio_preloaded_.from_num; + pending_from_radio_read_log_ = true; + from_radio_preloaded_valid_ = false; + from_radio_preloaded_ = Frame{}; + uint8_t empty = 0; + from_radio_.write(&empty, 0); +} + void MeshtasticBleService::handleConnectEvent(uint16_t conn_handle) { connected_ = true; - requestPairingIfNeeded(conn_handle); + conn_handle_ = conn_handle; + last_ble_activity_ms_ = millis(); + from_num_notify_enabled_ = false; + clearToPhoneQueue(); + pairing_request_pending_ = true; + pending_pairing_conn_handle_ = conn_handle; + pending_connect_conn_handle_ = conn_handle; + pending_connect_log_ = true; + bleLogBoth("[BLE][nrf52][mt][flow] link-up conn=%u adv=%u", + static_cast(conn_handle), + Bluefruit.Advertising.isRunning() ? 1U : 0U); } void MeshtasticBleService::handleDisconnectEvent(uint16_t conn_handle) { connected_ = false; + from_num_notify_enabled_ = false; + conn_handle_ = BLE_CONN_HANDLE_INVALID; + last_ble_activity_ms_ = millis(); if (phone_session_) { phone_session_->close(); } pending_passkey_.store(0); - Serial2.printf("[BLE][nrf52][mt] disconnected conn=%u\n", static_cast(conn_handle)); + pending_to_radio_head_ = 0; + pending_to_radio_tail_ = 0; + pending_to_radio_count_ = 0; + clearToPhoneQueue(); + pairing_request_pending_ = false; + pending_pairing_conn_handle_ = BLE_CONN_HANDLE_INVALID; + pending_disconnect_conn_handle_ = conn_handle; + pending_disconnect_log_ = true; + flushPendingConfigSaves(true); +} + +void MeshtasticBleService::handleFromNumCccdWrite(uint16_t conn_handle, uint16_t value) +{ + conn_handle_ = conn_handle; + last_ble_activity_ms_ = millis(); + from_num_notify_enabled_ = (value != 0U); + pending_from_num_cccd_conn_handle_ = conn_handle; + pending_from_num_cccd_value_ = value; + pending_from_num_cccd_log_ = true; + bleLogBoth("[BLE][nrf52][mt][flow] from_num subscribed=%u conn=%u value=0x%04X", + from_num_notify_enabled_ ? 1U : 0U, + static_cast(conn_handle), + static_cast(value)); } void MeshtasticBleService::handlePairPasskeyDisplay(uint16_t conn_handle, const uint8_t passkey[6], bool match_request) { const uint32_t parsed = parsePasskeyDigits(passkey); pending_passkey_.store(parsed); - Serial2.printf("[BLE][nrf52][mt] pairing passkey=%06lu match=%u conn=%u\n", - static_cast(parsed), - match_request ? 1U : 0U, - static_cast(conn_handle)); + (void)match_request; + (void)conn_handle; } void MeshtasticBleService::handlePairComplete(uint16_t conn_handle, uint8_t auth_status) { - Serial2.printf("[BLE][nrf52][mt] pair complete status=%u conn=%u\n", - static_cast(auth_status), - static_cast(conn_handle)); pending_passkey_.store(0); + pending_pair_complete_conn_handle_ = conn_handle; + pending_pair_complete_status_ = auth_status; + pending_pair_complete_log_ = true; } void MeshtasticBleService::handleSecured(uint16_t conn_handle) { - Serial2.printf("[BLE][nrf52][mt] secured conn=%u\n", static_cast(conn_handle)); pending_passkey_.store(0); + pending_secured_conn_handle_ = conn_handle; + pending_secured_log_ = true; } bool MeshtasticBleService::getPairingStatus(BlePairingStatus* out) const @@ -387,7 +1106,7 @@ bool MeshtasticBleService::getPairingStatus(BlePairingStatus* out) const } *out = BlePairingStatus{}; - out->available = ble_config_.enabled; + out->available = ctx_.isBleEnabled(); out->requires_passkey = ble_config_.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN; out->is_fixed_pin = ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN; out->is_connected = isBleConnected(); @@ -403,9 +1122,50 @@ bool MeshtasticBleService::isBleConnected() const void MeshtasticBleService::notifyFromNum(uint32_t from_num) { - if (active_ && Bluefruit.connected()) + pending_from_num_ = from_num; + pending_from_num_valid_ = true; + bleLogBoth("[BLE][nrf52][mt][flow] from_num pending=%08lX", static_cast(from_num)); +} + +void MeshtasticBleService::flushPendingFromNumNotify() +{ + if (!pending_from_num_valid_) { - from_num_.notify32(from_num); + return; + } + + const uint32_t from_num = pending_from_num_; + pending_from_num_valid_ = false; + + if (!active_ || !connected_) + { + bleLogBoth("[BLE][nrf52][mt][flow] from_num skip=%08lX reason=inactive active=%u connected=%u", + static_cast(from_num), + active_ ? 1U : 0U, + connected_ ? 1U : 0U); + return; + } + + if (!from_num_notify_enabled_ || conn_handle_ == BLE_CONN_HANDLE_INVALID) + { + bleLogBoth("[BLE][nrf52][mt][flow] from_num skip=%08lX reason=not-subscribed notify=%u conn=%u", + static_cast(from_num), + from_num_notify_enabled_ ? 1U : 0U, + static_cast(conn_handle_)); + return; + } + + from_num_.write32(from_num); + const bool ok = from_num_.notify32(conn_handle_, from_num); + bleLogBoth("[BLE][nrf52][mt][flow] from_num notify=%08lX conn=%u ok=%u cccd=0x%04X", + static_cast(from_num), + static_cast(conn_handle_), + ok ? 1U : 0U, + static_cast(from_num_.getCccd(conn_handle_))); + if (!ok && Bluefruit.connected()) + { + const bool fallback_ok = from_num_.notify32(from_num); + bleLogBoth("[BLE][nrf52][mt][flow] from_num notify fallback ok=%u", fallback_ok ? 1U : 0U); } } @@ -416,9 +1176,31 @@ bool MeshtasticBleService::loadBluetoothConfig(meshtastic_Config_BluetoothConfig return false; } *out = ble_config_; + out->enabled = ctx_.isBleEnabled(); return true; } +void MeshtasticBleService::saveBluetoothConfig(const meshtastic_Config_BluetoothConfig& config) +{ + ble_config_ = config; + ble_config_.enabled = config.enabled; + if (!isValidBluetoothMode(static_cast(ble_config_.mode))) + { + ble_config_.mode = meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN; + } + if (!isValidBlePin(ble_config_.fixed_pin) || + ble_config_.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) + { + ble_config_.fixed_pin = 0; + } + + markConfigSavePending(true, false); + bleLogBoth("[BLE][nrf52][mt] saveBluetoothConfig queued mode=%u pin=%06lu enabled=%u", + static_cast(ble_config_.mode), + static_cast(ble_config_.fixed_pin), + ble_config_.enabled ? 1U : 0U); +} + bool MeshtasticBleService::loadDeviceConnectionStatus(meshtastic_DeviceConnectionStatus* out) const { if (!out) @@ -442,19 +1224,107 @@ bool MeshtasticBleService::loadModuleConfig(meshtastic_LocalModuleConfig* out) c } *out = module_config_; + repairLegacyMqttConfig(out); return true; } void MeshtasticBleService::saveModuleConfig(const meshtastic_LocalModuleConfig& config) { - Serial2.printf("[BLE][nrf52][mt] saveModuleConfig mqtt enabled=%u proxy=%u enc=%u root=%s\n", - config.has_mqtt && config.mqtt.enabled ? 1U : 0U, - config.has_mqtt && config.mqtt.proxy_to_client_enabled ? 1U : 0U, - config.has_mqtt ? (config.mqtt.encryption_enabled ? 1U : 0U) : 1U, - config.has_mqtt ? config.mqtt.root : ""); + bleLogBoth("[BLE][nrf52][mt] saveModuleConfig mqtt enabled=%u proxy=%u enc=%u root=%s", + config.has_mqtt && config.mqtt.enabled ? 1U : 0U, + config.has_mqtt && config.mqtt.proxy_to_client_enabled ? 1U : 0U, + config.has_mqtt ? (config.mqtt.encryption_enabled ? 1U : 0U) : 1U, + config.has_mqtt ? config.mqtt.root : ""); module_config_ = config; + if (module_config_.version == 0) + { + module_config_.version = meshtastic_defaults::kModuleConfigVersion; + } + repairLegacyMqttConfig(&module_config_); + markConfigSavePending(false, true); syncMqttProxySettings(); - Serial2.printf("[BLE][nrf52][mt] saveModuleConfig done\n"); + bleLogBoth("[BLE][nrf52][mt] saveModuleConfig queued"); +} + +void MeshtasticBleService::markConfigSavePending(bool bluetooth_changed, bool module_changed) +{ + if (bluetooth_changed) + { + bluetooth_config_save_pending_ = true; + } + if (module_changed) + { + module_config_save_pending_ = true; + } + config_save_due_ms_ = millis() + kConfigSaveDebounceMs; +} + +void MeshtasticBleService::flushPendingConfigSaves(bool force) +{ + if (!bluetooth_config_save_pending_ && !module_config_save_pending_) + { + return; + } + + const uint32_t now_ms = millis(); + if (!force) + { + if (phone_session_ && phone_session_->isConfigFlowActive()) + { + return; + } + if (static_cast(now_ms - config_save_due_ms_) < 0) + { + return; + } + if (connected_ && (now_ms - last_ble_activity_ms_) < kConfigSaveDebounceMs) + { + return; + } + } + + meshtastic_Config_BluetoothConfig persisted_bluetooth = ble_config_; + persisted_bluetooth.enabled = ctx_.isBleEnabled(); + const bool needs_save = bluetooth_config_save_pending_ || module_config_save_pending_; + const bool persisted = needs_save ? savePersistedBleState(persisted_bluetooth, module_config_) : true; + ble_config_.enabled = persisted_bluetooth.enabled; + + bleLogBoth("[BLE][nrf52][mt] current mem persisted=%u bluetooth_config_save_pending_=%u module_config_save_pending_=%u needs_save=%u", + persisted ? 1U : 0U, + bluetooth_config_save_pending_ ? 1U : 0U, + module_config_save_pending_ ? 1U : 0U, + needs_save ? 1U : 0U); + + if (bluetooth_config_save_pending_) + { + bleLogBoth("[BLE][nrf52][mt] flush bluetooth cfg persisted=%u mode=%u pin=%06lu enabled=%u", + persisted ? 1U : 0U, + static_cast(persisted_bluetooth.mode), + static_cast(persisted_bluetooth.fixed_pin), + persisted_bluetooth.enabled ? 1U : 0U); + if (persisted) + { + bluetooth_config_save_pending_ = false; + } + } + + if (module_config_save_pending_) + { + bleLogBoth("[BLE][nrf52][mt] flush module cfg persisted=%u enabled=%u proxy=%u root=%s", + persisted ? 1U : 0U, + module_config_.has_mqtt && module_config_.mqtt.enabled ? 1U : 0U, + module_config_.has_mqtt && module_config_.mqtt.proxy_to_client_enabled ? 1U : 0U, + module_config_.mqtt.root); + if (persisted) + { + module_config_save_pending_ = false; + } + } + + if (!persisted) + { + config_save_due_ms_ = millis() + kConfigSaveDebounceMs; + } } bool MeshtasticBleService::handleMqttProxyToRadio(const meshtastic_MqttClientProxyMessage& msg) @@ -486,7 +1356,7 @@ void MeshtasticBleService::syncMqttProxySettings() settings.primary_downlink_enabled = cfg.primary_downlink_enabled; settings.secondary_uplink_enabled = cfg.secondary_uplink_enabled; settings.secondary_downlink_enabled = cfg.secondary_downlink_enabled; - settings.root = module_config_.mqtt.root[0] ? module_config_.mqtt.root : kDefaultMqttRoot; + settings.root = module_config_.mqtt.root[0] ? module_config_.mqtt.root : meshtastic_defaults::kDefaultMqttRoot; settings.primary_channel_id = channelDisplayName(ctx_, 0); settings.secondary_channel_id = channelDisplayName(ctx_, 1); mt->setMqttProxySettings(settings); @@ -533,36 +1403,65 @@ void MeshtasticBleService::requestPairingIfNeeded(uint16_t conn_handle) { return; } - - if (conn_handle == BLE_CONN_HANDLE_INVALID) - { - for (uint8_t index = 0; index < BLE_MAX_CONNECTION; ++index) - { - if (!Bluefruit.connected(index)) - { - continue; - } - BLEConnection* connection = Bluefruit.Connection(index); - if (connection) - { - const bool ok = connection->requestPairing(); - Serial2.printf("[BLE][nrf52][mt] requestPairing conn=%u ok=%u\n", - static_cast(index), - ok ? 1U : 0U); - } - } - return; - } - - BLEConnection* connection = Bluefruit.Connection(conn_handle); - if (!connection) - { - return; - } - const bool ok = connection->requestPairing(); - Serial2.printf("[BLE][nrf52][mt] requestPairing conn=%u ok=%u\n", + Serial2.printf("[BLE][nrf52][mt] pairing wait-for-central conn=%u mode=%u\n", static_cast(conn_handle), - ok ? 1U : 0U); + static_cast(ble_config_.mode)); +} + +void MeshtasticBleService::logDeferredBleEvents() +{ + if (pending_connect_log_) + { + pending_connect_log_ = false; + bleLogBoth("[BLE][nrf52][mt] connected conn=%u mode=%u", + static_cast(pending_connect_conn_handle_), + static_cast(ble_config_.mode)); + } + + if (pending_disconnect_log_) + { + pending_disconnect_log_ = false; + bleLogBoth("[BLE][nrf52][mt] disconnected conn=%u", + static_cast(pending_disconnect_conn_handle_)); + } + + if (pending_from_num_cccd_log_) + { + pending_from_num_cccd_log_ = false; + bleLogBoth("[BLE][nrf52][mt] from_num cccd conn=%u value=0x%04X enabled=%u", + static_cast(pending_from_num_cccd_conn_handle_), + static_cast(pending_from_num_cccd_value_), + from_num_notify_enabled_ ? 1U : 0U); + } + + if (pending_pair_complete_log_) + { + pending_pair_complete_log_ = false; + bleLogBoth("[BLE][nrf52][mt] pair complete status=%u conn=%u", + static_cast(pending_pair_complete_status_), + static_cast(pending_pair_complete_conn_handle_)); + } + + if (pending_secured_log_) + { + pending_secured_log_ = false; + bleLogBoth("[BLE][nrf52][mt] secured conn=%u", + static_cast(pending_secured_conn_handle_)); + } + + if (pending_from_radio_read_log_) + { + pending_from_radio_read_log_ = false; + bleLogBoth("[BLE][nrf52][mt] from_radio read len=%u from_num=%08lX", + static_cast(pending_from_radio_read_len_), + static_cast(pending_from_radio_read_from_num_)); + } + + if (pending_from_radio_empty_log_) + { + pending_from_radio_empty_log_ = false; + bleLogBoth("[BLE][nrf52][mt] from_radio read empty"); + } } uint32_t MeshtasticBleService::effectivePasskey() const diff --git a/platform/nrf52/arduino_common/src/chat/infra/blob_file_store.cpp b/platform/nrf52/arduino_common/src/chat/infra/blob_file_store.cpp index 8f2f0e34..48c59c3f 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/blob_file_store.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/blob_file_store.cpp @@ -3,19 +3,41 @@ #include "chat/infra/contact_store_core.h" #include "chat/infra/node_store_blob_format.h" #include "chat/infra/node_store_core.h" +#include "platform/nrf52/arduino_common/internal_fs_utils.h" #include +#include #include -#include namespace platform::nrf52::arduino_common::chat::infra { namespace { using Adafruit_LittleFS_Namespace::FILE_O_READ; -using Adafruit_LittleFS_Namespace::FILE_O_WRITE; -constexpr const char* kTempSuffix = ".tmp"; +constexpr const char* kLogTag = "[nrf52][blob_store]"; + +bool writeBytes(Adafruit_LittleFS_Namespace::File& file, const uint8_t* data, std::size_t len) +{ + if (!data && len != 0) + { + return false; + } + + constexpr std::size_t kChunkSize = 128; + std::size_t offset = 0; + while (offset < len) + { + const std::size_t chunk = std::min(kChunkSize, len - offset); + const std::size_t written = file.write(data + offset, chunk); + if (written != chunk) + { + return false; + } + offset += written; + } + return true; +} } // namespace @@ -123,7 +145,7 @@ bool BlobFileStore::loadBlob(std::vector& out) bool BlobFileStore::saveBlob(const uint8_t* data, size_t len) { - if (!path_ || !ensureFs()) + if (!path_ || !::platform::nrf52::arduino_common::internal_fs::ensureMounted(false, kLogTag)) { return false; } @@ -134,15 +156,14 @@ bool BlobFileStore::saveBlob(const uint8_t* data, size_t len) return true; } - std::string temp_path = std::string(path_) + kTempSuffix; - if (InternalFS.exists(temp_path.c_str())) + Adafruit_LittleFS_Namespace::File file(InternalFS); + if (!::platform::nrf52::arduino_common::internal_fs::openForOverwrite(path_, &file, false, kLogTag)) { - InternalFS.remove(temp_path.c_str()); + return false; } - - auto file = InternalFS.open(temp_path.c_str(), FILE_O_WRITE); - if (!file) + if (!::platform::nrf52::arduino_common::internal_fs::rewindForOverwrite(file)) { + file.close(); return false; } @@ -152,24 +173,16 @@ bool BlobFileStore::saveBlob(const uint8_t* data, size_t len) header.payload_len = static_cast(len); header.crc = computeCrc(data, len); - size_t written = file.write(reinterpret_cast(&header), sizeof(header)); - if (written == sizeof(header) && data && len > 0) + uint32_t final_size = static_cast(sizeof(header) + len); + bool ok = writeBytes(file, reinterpret_cast(&header), sizeof(header)); + if (ok && len > 0) { - written += file.write(data, len); + ok = writeBytes(file, data, len); } + const bool trunc_ok = ok && ::platform::nrf52::arduino_common::internal_fs::truncateAfterWrite(file, final_size); file.flush(); file.close(); - if (written != (sizeof(header) + len)) - { - InternalFS.remove(temp_path.c_str()); - return false; - } - - if (InternalFS.exists(path_)) - { - InternalFS.remove(path_); - } - return InternalFS.rename(temp_path.c_str(), path_); + return ok && trunc_ok; } void BlobFileStore::clearBlob() @@ -178,10 +191,7 @@ void BlobFileStore::clearBlob() { return; } - if (InternalFS.exists(path_)) - { - InternalFS.remove(path_); - } + ::platform::nrf52::arduino_common::internal_fs::removeIfExists(path_); } uint32_t BlobFileStore::computeCrc(const uint8_t* data, size_t len) diff --git a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp index 27d666e1..c9e613d3 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp @@ -43,6 +43,7 @@ constexpr uint32_t kNodeInfoIntervalMs = 3UL * 60UL * 60UL * 1000UL; constexpr uint32_t kNodeInfoReplySuppressMs = 12UL * 60UL * 60UL * 1000UL; constexpr uint32_t kPositionReplySuppressMs = 3UL * 60UL * 1000UL; constexpr uint32_t kKeyVerificationTimeoutMs = 60000UL; +constexpr uint32_t kPkiNodeSaveDebounceMs = 2000UL; constexpr ::chat::NodeId kBroadcastNode = 0xFFFFFFFFUL; constexpr uint8_t kDefaultPskIndex = 1; constexpr uint8_t kBitfieldWantResponseMask = 0x02U; @@ -66,6 +67,97 @@ using ::chat::meshtastic::readPbString; using ::chat::meshtastic::shouldSetAirWantAck; using ::chat::meshtastic::toHex; +void logMeshtasticRx(const char* format, ...); +uint32_t nowSeconds(); + +static constexpr size_t kNodeShortNameMax = 10; +static constexpr size_t kNodeLongNameMax = 32; + +template +void copyCStringTrunc(char (&dst)[N], const char* src) +{ + if constexpr (N == 0) + { + return; + } + + if (!src) + { + dst[0] = '\0'; + return; + } + + std::strncpy(dst, src, N - 1); + dst[N - 1] = '\0'; +} + +bool resolveTrustedNodeInfoOwner(::chat::NodeId self_node_id, + const ::chat::meshtastic::PacketHeaderWire& header, + uint32_t claimed_node_num, + ::chat::NodeId* out_effective_node_id) +{ + if (!out_effective_node_id) + { + return false; + } + + const ::chat::NodeId effective_node_id = header.from; + + // payload 声称的 num 与真实发件人不一致,直接拒绝 + if (claimed_node_num != 0 && claimed_node_num != header.from) + { + logMeshtasticRx("[gat562][mt] reject nodeinfo mismatch from=%08lX claimed=%08lX\n", + static_cast(header.from), + static_cast(claimed_node_num)); + return false; + } + + // 外部节点不能借 NodeInfo 更新本机条目 + if (effective_node_id == self_node_id && header.from != self_node_id) + { + logMeshtasticRx("[gat562][mt] reject foreign nodeinfo targeting self from=%08lX\n", + static_cast(header.from)); + return false; + } + + *out_effective_node_id = effective_node_id; + return true; +} + +void fillCommonNodeUpdateFields(::chat::contacts::NodeUpdate* update, + float last_seen_snr, + float last_seen_rssi, + uint8_t hops_away, + ::chat::ChannelId channel, + bool via_mqtt) +{ + if (!update) + { + return; + } + + update->has_last_seen = true; + update->last_seen = nowSeconds(); + + update->has_snr = !std::isnan(last_seen_snr); + update->snr = last_seen_snr; + + update->has_rssi = !std::isnan(last_seen_rssi); + update->rssi = last_seen_rssi; + + update->has_protocol = true; + update->protocol = static_cast(::chat::contacts::NodeProtocolType::Meshtastic); + + update->has_hops_away = true; + update->hops_away = hops_away; + + update->has_channel = true; + update->channel = static_cast(channel); + + update->has_via_mqtt = true; + update->via_mqtt = via_mqtt; +} + void logMeshtasticRx(const char* format, ...) { char buffer[192] = {}; @@ -526,8 +618,11 @@ bool MeshtasticRadioAdapter::sendTextWithId(::chat::ChannelId channel, const std return ok; } - uint8_t payload[256] = {}; - size_t payload_size = sizeof(payload); + auto& scratch = tx_scratch_; + std::fill(scratch.app_data.begin(), scratch.app_data.end(), 0); + std::fill(scratch.wire.begin(), scratch.wire.end(), 0); + uint8_t* payload = scratch.app_data.data(); + size_t payload_size = scratch.app_data.size(); const ::chat::MessageId packet_id = (forced_msg_id != 0) ? forced_msg_id : next_packet_id_++; if (forced_msg_id != 0 && forced_msg_id >= next_packet_id_) { @@ -554,11 +649,11 @@ bool MeshtasticRadioAdapter::sendTextWithId(::chat::ChannelId channel, const std const uint8_t channel_hash = ::chat::meshtastic::computeChannelHash(channelNameFor(config_, out_channel), key, key_len); - const bool track_ack = (dest != kBroadcastNode); + const bool track_ack = true; const bool air_want_ack = shouldSetAirWantAck(dest, track_ack); - uint8_t wire[384] = {}; - size_t wire_size = sizeof(wire); + uint8_t* wire = scratch.wire.data(); + size_t wire_size = scratch.wire.size(); if (!::chat::meshtastic::buildWirePacket(payload, payload_size, node_id_, @@ -587,7 +682,7 @@ bool MeshtasticRadioAdapter::sendTextWithId(::chat::ChannelId channel, const std std::memcpy(mqtt_data.payload.bytes, text.data(), mqtt_data.payload.size); } - if (!transmitPreparedWire(wire, wire_size, out_channel, &mqtt_data, true, true, 0, true)) + if (!transmitPreparedWire(wire, wire_size, out_channel, &mqtt_data, track_ack, true, 0, true)) { return false; } @@ -636,8 +731,12 @@ bool MeshtasticRadioAdapter::sendAppData(::chat::ChannelId channel, uint32_t por const bool effective_want_response = want_response || want_ack; - uint8_t data_pb[256] = {}; - size_t data_pb_size = sizeof(data_pb); + auto& scratch = tx_scratch_; + std::fill(scratch.app_data.begin(), scratch.app_data.end(), 0); + std::fill(scratch.aux_data.begin(), scratch.aux_data.end(), 0); + std::fill(scratch.wire.begin(), scratch.wire.end(), 0); + uint8_t* data_pb = scratch.app_data.data(); + size_t data_pb_size = scratch.app_data.size(); if (!::chat::meshtastic::encodeAppData(portnum, payload, len, effective_want_response, data_pb, &data_pb_size)) { return false; @@ -665,7 +764,7 @@ bool MeshtasticRadioAdapter::sendAppData(::chat::ChannelId channel, uint32_t por const uint8_t* wire_payload = data_pb; size_t wire_payload_len = data_pb_size; bool use_pki = false; - bool track_ack = want_ack && !is_broadcast; + bool track_ack = want_ack; if (wire_dest != kBroadcastNode && pki_enabled_) { if (!pki_ready_ || !allowPkiForPortnum(portnum) || !hasPkiKey(wire_dest)) @@ -673,8 +772,8 @@ bool MeshtasticRadioAdapter::sendAppData(::chat::ChannelId channel, uint32_t por return false; } - uint8_t pki_buf[256] = {}; - size_t pki_len = sizeof(pki_buf); + uint8_t* pki_buf = scratch.aux_data.data(); + size_t pki_len = scratch.aux_data.size(); if (!encryptPkiPayload(wire_dest, packet_id, data_pb, data_pb_size, pki_buf, &pki_len)) { return false; @@ -690,8 +789,8 @@ bool MeshtasticRadioAdapter::sendAppData(::chat::ChannelId channel, uint32_t por use_pki = true; } - uint8_t wire[384] = {}; - size_t wire_size = sizeof(wire); + uint8_t* wire = scratch.wire.data(); + size_t wire_size = scratch.wire.size(); if (!::chat::meshtastic::buildWirePacket(wire_payload, wire_payload_len, node_id_, @@ -868,18 +967,19 @@ bool MeshtasticRadioAdapter::handleMqttProxyMessage(const meshtastic_MqttClientP return false; } - meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; - char channel_id[32] = {0}; - char gateway_id[16] = {0}; + auto& scratch = mqtt_scratch_; + std::memset(&scratch.packet, 0, sizeof(scratch.packet)); + std::memset(scratch.channel_id, 0, sizeof(scratch.channel_id)); + std::memset(scratch.gateway_id, 0, sizeof(scratch.gateway_id)); if (!decodeMqttServiceEnvelope(data_field->bytes, data_field->size, - &packet, - channel_id, sizeof(channel_id), - gateway_id, sizeof(gateway_id))) + &scratch.packet, + scratch.channel_id, sizeof(scratch.channel_id), + scratch.gateway_id, sizeof(scratch.gateway_id))) { return false; } - return injectMqttEnvelope(packet, channel_id, gateway_id); + return injectMqttEnvelope(scratch.packet, scratch.channel_id, scratch.gateway_id); } bool MeshtasticRadioAdapter::pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) @@ -911,9 +1011,17 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) has_pending_raw_packet_ = true; } - ::chat::meshtastic::PacketHeaderWire header{}; - uint8_t payload[256] = {}; - size_t payload_size = sizeof(payload); + // Keep large protobuf scratch buffers off the task stack. This path can nest + // MQTT proxy packaging and BLE forwarding on nRF52. + auto& rx = rx_scratch_; + std::memset(&rx.header, 0, sizeof(rx.header)); + std::fill(rx.payload.begin(), rx.payload.end(), 0); + std::fill(rx.plain.begin(), rx.plain.end(), 0); + std::memset(&rx.decoded, 0, sizeof(rx.decoded)); + + auto& header = rx.header; + uint8_t* payload = rx.payload.data(); + size_t payload_size = rx.payload.size(); if (!::chat::meshtastic::parseWirePacket(data, size, &header, payload, &payload_size)) { logMeshtasticRx("[gat562][mt] parse fail len=%u\n", static_cast(size)); @@ -935,16 +1043,18 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) } } - uint8_t plain[256] = {}; - size_t plain_len = sizeof(plain); + uint8_t* plain = rx.plain.data(); + size_t plain_len = rx.plain.size(); ::chat::ChannelId channel = ::chat::ChannelId::PRIMARY; size_t key_len = 0; const uint8_t* key = selectKeyByHash(config_, header.channel, &key_len, &channel); const uint8_t primary_hash = channelHashFor(config_, ::chat::ChannelId::PRIMARY); const uint8_t secondary_hash = channelHashFor(config_, ::chat::ChannelId::SECONDARY); const bool want_ack_flag = (header.flags & ::chat::meshtastic::PACKET_FLAGS_WANT_ACK_MASK) != 0; - meshtastic_Data decoded = meshtastic_Data_init_zero; + + auto& decoded = rx.decoded; bool decoded_ok = false; + if (header.channel == 0) { auto channel_it = node_last_channel_.find(header.from); @@ -957,17 +1067,24 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) { return; } + if (!pki_ready_) { (void)buildAndQueueNodeInfo(header.from, true, ::chat::ChannelId::PRIMARY); + size_t primary_key_len = 0; const uint8_t* primary_key = selectKey(config_, ::chat::ChannelId::PRIMARY, &primary_key_len); - (void)sendRoutingError(header.from, header.id, primary_hash, + (void)sendRoutingError(header.from, + header.id, + primary_hash, ::chat::ChannelId::PRIMARY, - primary_key, primary_key_len, - meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, 0); + primary_key, + primary_key_len, + meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, + 0); return; } + if (!decryptPkiPayload(header.from, header.id, payload, payload_size, plain, &plain_len)) { return; @@ -1018,6 +1135,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) static_cast(header.channel), static_cast(key_len), static_cast(want_ack_flag ? 1U : 0U)); + size_t primary_key_len = 0; const uint8_t* primary_key = selectKey(config_, ::chat::ChannelId::PRIMARY, &primary_key_len); @@ -1026,18 +1144,26 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) if (header.channel == 0) { (void)buildAndQueueNodeInfo(header.from, true, ::chat::ChannelId::PRIMARY); - (void)sendRoutingError(header.from, header.id, primary_hash, + (void)sendRoutingError(header.from, + header.id, + primary_hash, ::chat::ChannelId::PRIMARY, - primary_key, primary_key_len, - meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, 0); + primary_key, + primary_key_len, + meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, + 0); } else if (header.channel != primary_hash && header.channel != secondary_hash) { (void)buildAndQueueNodeInfo(header.from, true, ::chat::ChannelId::PRIMARY); - (void)sendRoutingError(header.from, header.id, primary_hash, + (void)sendRoutingError(header.from, + header.id, + primary_hash, ::chat::ChannelId::PRIMARY, - primary_key, primary_key_len, - meshtastic_Routing_Error_NO_CHANNEL, 0); + primary_key, + primary_key_len, + meshtastic_Routing_Error_NO_CHANNEL, + 0); } } return; @@ -1056,6 +1182,11 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) const auto pending_it = pending_retransmits_.find(pendingKey(header.from, header.id)); if (is_broadcast && pending_it != pending_retransmits_.end()) { + logMeshtasticRx("[gat562][mt] implicit-ack observed self-broadcast id=%08lX relay=%u next=%u ch=%u\n", + static_cast(header.id), + static_cast(header.relay_node), + static_cast(header.next_hop), + static_cast(header.channel)); ::chat::RxMeta implicit_rx{}; implicit_rx.rx_timestamp_ms = millis(); implicit_rx.rx_timestamp_s = nowSeconds(); @@ -1064,12 +1195,10 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) implicit_rx.channel_hash = header.channel; implicit_rx.next_hop = header.next_hop; implicit_rx.relay_node = header.relay_node; - const ::chat::NodeId ack_from = - (header.relay_node != 0) ? static_cast<::chat::NodeId>(header.relay_node) : node_id_; pending_retransmits_.erase(pending_it); emitRoutingResult(header.id, meshtastic_Routing_Error_NONE, - ack_from, + node_id_, node_id_, channel, header.channel, @@ -1130,6 +1259,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) } } } + if (decoded_ok) { logMeshtasticRx("[gat562][mt] decoded from=%08lX to=%08lX id=%lu port=%u ch=%u hop=%u\n", @@ -1149,6 +1279,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) static_cast(plain_len), static_cast(header.channel)); } + if (decoded_ok) { queueMqttProxyPublishFromWire(data, size, &decoded, channel); @@ -1163,35 +1294,157 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) maybeHandleObservedRelay(header); const bool duplicate = history.seen_recently && !history.was_upgraded; + auto apply_observed_node_update = [&](::chat::NodeId node_id, const ::chat::contacts::NodeUpdate& update) + { + if (contact_service_) + { + contact_service_->applyNodeUpdate(node_id, update); + } + else if (node_store_) + { + node_store_->applyUpdate(node_id, update); + } + }; - if (decoded_ok && decoded.portnum == meshtastic_PortNum_NODEINFO_APP && decoded.payload.size > 0 && node_store_) + // ------------------------------------------------------------ + // NODEINFO / USER: 保守版,只更新观测字段,先不要把身份字段写进 store + // ------------------------------------------------------------ + if (decoded_ok && + decoded.portnum == meshtastic_PortNum_NODEINFO_APP && + decoded.payload.size > 0 && + (node_store_ || contact_service_)) { meshtastic_NodeInfo node = meshtastic_NodeInfo_init_default; pb_istream_t nstream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); + if (pb_decode(&nstream, meshtastic_NodeInfo_fields, &node)) { - const uint32_t node_id = node.num ? node.num : header.from; - const char* short_name = node.has_user ? node.user.short_name : ""; - const char* long_name = node.has_user ? node.user.long_name : ""; - uint8_t role = ::chat::contacts::kNodeRoleUnknown; - if (node.has_user && node.user.role <= meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) + const ::chat::NodeId effective_node_id = header.from; + + if (node.num != 0 && node.num != header.from) { - role = static_cast(node.user.role); + logMeshtasticRx("[gat562][mt] reject nodeinfo mismatch from=%08lX claimed=%08lX\n", + static_cast(header.from), + static_cast(node.num)); } - const float snr = std::isnan(last_rx_snr_) ? node.snr : last_rx_snr_; - const uint8_t hops_away = - node.has_hops_away ? node.hops_away : ::chat::meshtastic::computeHopsAway(header.flags); - node_store_->upsert(node_id, short_name, long_name, - nowSeconds(), - snr, last_rx_rssi_, - static_cast(::chat::contacts::NodeProtocolType::Meshtastic), - role, hops_away, - static_cast(node.has_user ? node.user.hw_model : 0), - static_cast(channel)); - nodeinfo_last_seen_ms_[node_id] = millis(); - if (node.has_user && node.user.public_key.size == 32) + else if (effective_node_id == node_id_ && header.from != node_id_) { - savePkiNodeKey(node_id, node.user.public_key.bytes, node.user.public_key.size); + logMeshtasticRx("[gat562][mt] reject foreign nodeinfo targeting self from=%08lX\n", + static_cast(header.from)); + } + else + { + const float snr = std::isnan(last_rx_snr_) ? node.snr : last_rx_snr_; + const uint8_t hops_away = + node.has_hops_away ? node.hops_away + : ::chat::meshtastic::computeHopsAway(header.flags); + + ::chat::contacts::NodeUpdate update{}; + update.has_last_seen = true; + update.last_seen = nowSeconds(); + update.has_snr = !std::isnan(snr); + update.snr = snr; + update.has_rssi = !std::isnan(last_rx_rssi_); + update.rssi = last_rx_rssi_; + update.has_protocol = true; + update.protocol = static_cast(::chat::contacts::NodeProtocolType::Meshtastic); + update.has_hops_away = true; + update.hops_away = hops_away; + update.has_channel = true; + update.channel = static_cast(channel); + update.has_via_mqtt = true; + update.via_mqtt = node.via_mqtt || + ((header.flags & ::chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0); + update.has_is_ignored = true; + update.is_ignored = node.is_ignored; + if (node.has_position && ::chat::meshtastic::hasValidPosition(node.position)) + { + update.has_position = true; + update.position.valid = true; + update.position.latitude_i = node.position.latitude_i; + update.position.longitude_i = node.position.longitude_i; + update.position.has_altitude = node.position.has_altitude; + update.position.altitude = node.position.altitude; + update.position.timestamp = + node.position.timestamp != 0 ? node.position.timestamp : node.position.time; + update.position.precision_bits = node.position.precision_bits; + update.position.pdop = node.position.PDOP; + update.position.hdop = node.position.HDOP; + update.position.vdop = node.position.VDOP; + update.position.gps_accuracy_mm = node.position.gps_accuracy; + } + if (node.has_device_metrics) + { + update.has_device_metrics = true; + update.device_metrics.has_battery_level = node.device_metrics.has_battery_level; + update.device_metrics.battery_level = node.device_metrics.battery_level; + update.device_metrics.has_voltage = node.device_metrics.has_voltage; + update.device_metrics.voltage = node.device_metrics.voltage; + update.device_metrics.has_channel_utilization = node.device_metrics.has_channel_utilization; + update.device_metrics.channel_utilization = node.device_metrics.channel_utilization; + update.device_metrics.has_air_util_tx = node.device_metrics.has_air_util_tx; + update.device_metrics.air_util_tx = node.device_metrics.air_util_tx; + update.device_metrics.has_uptime_seconds = node.device_metrics.has_uptime_seconds; + update.device_metrics.uptime_seconds = node.device_metrics.uptime_seconds; + } + + if (node.has_user) + { + if (node.user.short_name[0] != '\0') + { + update.short_name = node.user.short_name; + } + if (node.user.long_name[0] != '\0') + { + update.long_name = node.user.long_name; + } + update.has_role = true; + update.role = static_cast(node.user.role); + if (node.user.hw_model != meshtastic_HardwareModel_UNSET) + { + update.has_hw_model = true; + update.hw_model = static_cast(node.user.hw_model); + } + + bool has_macaddr = false; + for (std::size_t idx = 0; idx < sizeof(update.macaddr); ++idx) + { + if (node.user.macaddr[idx] != 0) + { + has_macaddr = true; + break; + } + } + if (has_macaddr) + { + update.has_macaddr = true; + std::memcpy(update.macaddr, node.user.macaddr, sizeof(update.macaddr)); + } + + update.has_public_key = true; + update.public_key_present = (node.user.public_key.size > 0); + } + + if (!duplicate || history.was_fallback) + { + apply_observed_node_update(effective_node_id, update); + } + nodeinfo_last_seen_ms_[effective_node_id] = millis(); + + if (node.has_user) + { + logMeshtasticRx("[gat562][mt] nodeinfo observed from=%08lX owner=%08lX short=\"%s\" long=\"%s\"\n", + static_cast(header.from), + static_cast(effective_node_id), + node.user.short_name, + node.user.long_name); + } + else + { + logMeshtasticRx("[gat562][mt] nodeinfo observed from=%08lX owner=%08lX\n", + static_cast(header.from), + static_cast(effective_node_id)); + } } } else @@ -1200,22 +1453,75 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) pb_istream_t ustream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); if (pb_decode(&ustream, meshtastic_User_fields, &user)) { - uint8_t role = ::chat::contacts::kNodeRoleUnknown; - if (user.role <= meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) + const ::chat::NodeId effective_node_id = header.from; + + if (effective_node_id == node_id_ && header.from != node_id_) { - role = static_cast(user.role); + logMeshtasticRx("[gat562][mt] reject foreign user payload targeting self from=%08lX\n", + static_cast(header.from)); } - node_store_->upsert(header.from, user.short_name, user.long_name, - nowSeconds(), - last_rx_snr_, last_rx_rssi_, - static_cast(::chat::contacts::NodeProtocolType::Meshtastic), - role, ::chat::meshtastic::computeHopsAway(header.flags), - static_cast(user.hw_model), - static_cast(channel)); - nodeinfo_last_seen_ms_[header.from] = millis(); - if (user.public_key.size == 32) + else { - savePkiNodeKey(header.from, user.public_key.bytes, user.public_key.size); + ::chat::contacts::NodeUpdate update{}; + if (user.short_name[0] != '\0') + { + update.short_name = user.short_name; + } + if (user.long_name[0] != '\0') + { + update.long_name = user.long_name; + } + update.has_last_seen = true; + update.last_seen = nowSeconds(); + update.has_snr = !std::isnan(last_rx_snr_); + update.snr = last_rx_snr_; + update.has_rssi = !std::isnan(last_rx_rssi_); + update.rssi = last_rx_rssi_; + update.has_protocol = true; + update.protocol = static_cast(::chat::contacts::NodeProtocolType::Meshtastic); + update.has_hops_away = true; + update.hops_away = ::chat::meshtastic::computeHopsAway(header.flags); + update.has_channel = true; + update.channel = static_cast(channel); + update.has_via_mqtt = true; + update.via_mqtt = ((header.flags & ::chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0); + update.has_role = true; + update.role = static_cast(user.role); + if (user.hw_model != meshtastic_HardwareModel_UNSET) + { + update.has_hw_model = true; + update.hw_model = static_cast(user.hw_model); + } + + bool has_macaddr = false; + for (std::size_t idx = 0; idx < sizeof(update.macaddr); ++idx) + { + if (user.macaddr[idx] != 0) + { + has_macaddr = true; + break; + } + } + if (has_macaddr) + { + update.has_macaddr = true; + std::memcpy(update.macaddr, user.macaddr, sizeof(update.macaddr)); + } + + update.has_public_key = true; + update.public_key_present = (user.public_key.size > 0); + + if (!duplicate || history.was_fallback) + { + apply_observed_node_update(effective_node_id, update); + } + nodeinfo_last_seen_ms_[effective_node_id] = millis(); + + logMeshtasticRx("[gat562][mt] user observed from=%08lX owner=%08lX short=\"%s\" long=\"%s\"\n", + static_cast(header.from), + static_cast(effective_node_id), + user.short_name, + user.long_name); } } } @@ -1257,9 +1563,11 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) (void)sendRoutingAck(header.from, header.id, header.channel, channel, key, key_len, 0); } - const bool want_response = decoded_ok && - (decoded.want_response || - (decoded.has_bitfield && ((decoded.bitfield & kBitfieldWantResponseMask) != 0))); + const bool want_response = + decoded_ok && + (decoded.want_response || + (decoded.has_bitfield && ((decoded.bitfield & kBitfieldWantResponseMask) != 0))); + if (decoded_ok && decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { (void)handleTraceRoutePacket(header, &decoded, &rx_meta, channel, want_ack_flag, want_response); @@ -1269,6 +1577,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) { meshtastic_KeyVerification kv = meshtastic_KeyVerification_init_zero; bool handled = false; + if (header.channel == 0 && ::chat::meshtastic::decodeKeyVerificationMessage(plain, plain_len, &kv)) { if (kv.hash1.size == 0 && kv.hash2.size == 0) @@ -1284,6 +1593,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) handled = handleKeyVerificationFinal(header, kv); } } + if (!handled) { logMeshtasticRx("[gat562][mt] key verification ignored from=%08lX stage=%s\n", @@ -1331,39 +1641,65 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) return; } + const bool is_text_port = + decoded_ok && + (decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP || + decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP); + const bool text_compressed = + decoded_ok && (decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP); + + if (is_text_port) + { + logMeshtasticRx("[gat562][mt][text] rx from=%08lX to=%08lX id=%lu port=%u payload=%u plain=%u compressed=%u encrypted=%u\n", + static_cast(header.from), + static_cast(header.to), + static_cast(header.id), + static_cast(decoded.portnum), + static_cast(decoded.payload.size), + static_cast(plain_len), + text_compressed ? 1U : 0U, + key_len > 0 ? 1U : 0U); + } + ::chat::MeshIncomingText incoming{}; - if (::chat::meshtastic::decodeTextMessage(plain, plain_len, &incoming)) + if (decoded_ok && ::chat::meshtastic::decodeTextPayload(decoded, &incoming)) { incoming.from = header.from; incoming.to = header.to; incoming.msg_id = header.id; incoming.channel = channel; + incoming.timestamp = (rx_meta.rx_timestamp_s != 0) ? rx_meta.rx_timestamp_s : nowSeconds(); incoming.encrypted = key_len > 0; incoming.hop_limit = hop_limit; incoming.rx_meta = rx_meta; - logMeshtasticRx("[gat562][mt] text queued from=%08lX to=%08lX id=%lu len=%u text=\"%s\"\n", + + logMeshtasticRx("[gat562][mt] text queued from=%08lX to=%08lX id=%lu port=%u payload=%u compressed=%u len=%u text=\"%s\"\n", static_cast(incoming.from), static_cast(incoming.to), static_cast(incoming.msg_id), + static_cast(decoded.portnum), + static_cast(decoded.payload.size), + text_compressed ? 1U : 0U, static_cast(incoming.text.size()), incoming.text.c_str()); + text_queue_.push(std::move(incoming)); return; } - if (decoded_ok && - (decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP || - decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP)) + if (is_text_port) { - logMeshtasticRx("[gat562][mt] text decode fail from=%08lX id=%lu payload=%u plain=%u\n", + logMeshtasticRx("[gat562][mt] text decode fail from=%08lX id=%lu port=%u payload=%u plain=%u compressed=%u\n", static_cast(header.from), static_cast(header.id), + static_cast(decoded.portnum), static_cast(decoded.payload.size), - static_cast(plain_len)); + static_cast(plain_len), + text_compressed ? 1U : 0U); } ::chat::MeshIncomingData app_data{}; - if (::chat::meshtastic::decodeAppData(plain, plain_len, &app_data)) + if (decoded_ok && ::chat::meshtastic::decodeAppPayload(decoded, &app_data)) { app_data.from = header.from; app_data.to = header.to; @@ -1374,12 +1710,14 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) app_data.hop_limit = hop_limit; app_data.want_response = want_response; app_data.rx_meta = rx_meta; + logMeshtasticRx("[gat562][mt] app queued from=%08lX to=%08lX id=%lu port=%u len=%u\n", static_cast(app_data.from), static_cast(app_data.to), static_cast(app_data.packet_id), static_cast(app_data.portnum), static_cast(app_data.payload.size())); + data_queue_.push(std::move(app_data)); } } @@ -1406,6 +1744,15 @@ void MeshtasticRadioAdapter::processSendQueue() auto* header = reinterpret_cast<::chat::meshtastic::PacketHeaderWire*>(pending.wire.data()); if (pending.retries_left == 0) { + if (pending.observe_only) + { + logMeshtasticRx("[gat562][mt] observe timeout id=%08lX dest=%08lX ch=%u local=%u want_ack=%u\n", + static_cast(pending.packet_id), + static_cast(pending.dest), + static_cast(pending.channel), + pending.local_origin ? 1U : 0U, + pending.want_ack ? 1U : 0U); + } if (pending.local_origin && pending.want_ack) { emitRoutingResult(pending.packet_id, @@ -1450,6 +1797,25 @@ void MeshtasticRadioAdapter::processSendQueue() } } +void MeshtasticRadioAdapter::flushDeferredPersistence(bool force) +{ + if (!pki_node_keys_dirty_) + { + return; + } + + const uint32_t now_ms = millis(); + if (!force && static_cast(now_ms - pki_node_keys_save_due_ms_) < 0) + { + return; + } + + if (!savePkiKeysToPrefs()) + { + pki_node_keys_save_due_ms_ = millis() + kPkiNodeSaveDebounceMs; + } +} + ::chat::runtime::EffectiveSelfIdentity MeshtasticRadioAdapter::buildEffectiveIdentity() const { ::chat::runtime::EffectiveSelfIdentity identity{}; @@ -2184,6 +2550,14 @@ void MeshtasticRadioAdapter::queuePendingRetransmit(const ::chat::meshtastic::Pa : (pending.want_ack ? kDefaultAckRetries : kDefaultNextHopRetries); pending.next_tx_ms = millis() + kRetransmitIntervalMs; } + logMeshtasticRx("[gat562][mt] watch pending id=%08lX from=%08lX dest=%08lX observe=%u local=%u want_ack=%u next=%lu\n", + static_cast(pending.packet_id), + static_cast(pending.original_from), + static_cast(pending.dest), + pending.observe_only ? 1U : 0U, + pending.local_origin ? 1U : 0U, + pending.want_ack ? 1U : 0U, + static_cast(pending.next_tx_ms)); pending_retransmits_[pendingKey(header.from, header.id)] = std::move(pending); } @@ -2287,21 +2661,50 @@ void MeshtasticRadioAdapter::loadPkiNodeKeys() } } -void MeshtasticRadioAdapter::savePkiNodeKey(::chat::NodeId node_id, const uint8_t* key, size_t key_len) +void MeshtasticRadioAdapter::savePkiNodeKey(::chat::NodeId node_id, + const uint8_t* key, + size_t key_len) { - if (node_id == 0 || !key || key_len != 32) + if (node_id == 0) { return; } + if (!key || key_len != 32) + { + logMeshtasticRx("[gat562][mt] ignore invalid pki key node=%08lX len=%u\n", + static_cast(node_id), + static_cast(key_len)); + return; + } + std::array key_copy{}; std::memcpy(key_copy.data(), key, key_copy.size()); - node_public_keys_[node_id] = key_copy; + + auto it = node_public_keys_.find(node_id); + const bool existed = (it != node_public_keys_.end()); + const bool changed = !existed || (it->second != key_copy); + + if (changed) + { + node_public_keys_[node_id] = key_copy; + markPkiKeysDirty(); + + logMeshtasticRx("[gat562][mt] pki key updated node=%08lX\n", + static_cast(node_id)); + } + + // 无论 key 是否变化,都刷新最近看到该节点公钥的时间 touchPkiNodeKey(node_id); - savePkiKeysToPrefs(); } -void MeshtasticRadioAdapter::savePkiKeysToPrefs() +void MeshtasticRadioAdapter::markPkiKeysDirty() +{ + pki_node_keys_dirty_ = true; + pki_node_keys_save_due_ms_ = millis() + kPkiNodeSaveDebounceMs; +} + +bool MeshtasticRadioAdapter::savePkiKeysToPrefs() { struct PkiKeyEntry { @@ -2338,9 +2741,14 @@ void MeshtasticRadioAdapter::savePkiKeysToPrefs() entries.erase(entries.begin(), entries.begin() + static_cast(drop)); } - (void)platform::ui::settings_store::put_blob(kPkiNodeNs, kPkiNodeKey, - entries.empty() ? nullptr : entries.data(), - entries.size() * sizeof(PkiKeyEntry)); + const bool persisted = platform::ui::settings_store::put_blob(kPkiNodeNs, kPkiNodeKey, + entries.empty() ? nullptr : entries.data(), + entries.size() * sizeof(PkiKeyEntry)); + if (persisted) + { + pki_node_keys_dirty_ = false; + } + return persisted; } void MeshtasticRadioAdapter::touchPkiNodeKey(::chat::NodeId node_id) @@ -2908,7 +3316,9 @@ bool MeshtasticRadioAdapter::injectMqttEnvelope(const meshtastic_MeshPacket& pac } } - uint8_t wire_buffer[sizeof(::chat::meshtastic::PacketHeaderWire) + 256] = {}; + auto& scratch = mqtt_scratch_; + std::fill(scratch.wire.begin(), scratch.wire.end(), 0); + uint8_t* wire_buffer = scratch.wire.data(); size_t wire_size = sizeof(::chat::meshtastic::PacketHeaderWire); if (packet.which_payload_variant == meshtastic_MeshPacket_encrypted_tag) @@ -2932,7 +3342,7 @@ bool MeshtasticRadioAdapter::injectMqttEnvelope(const meshtastic_MeshPacket& pac const size_t enc_size = std::min(static_cast(packet.encrypted.size), sizeof(packet.encrypted.bytes)); - if (wire_size + enc_size > sizeof(wire_buffer)) + if (wire_size + enc_size > scratch.wire.size()) { return false; } @@ -2941,13 +3351,15 @@ bool MeshtasticRadioAdapter::injectMqttEnvelope(const meshtastic_MeshPacket& pac } else { - meshtastic_Data decoded = packet.decoded; + std::memset(&scratch.decoded, 0, sizeof(scratch.decoded)); + scratch.decoded = packet.decoded; + meshtastic_Data& decoded = scratch.decoded; decoded.dest = packet.to; decoded.source = packet.from; decoded.has_bitfield = true; - uint8_t data_buffer[256] = {}; - pb_ostream_t dstream = pb_ostream_from_buffer(data_buffer, sizeof(data_buffer)); + std::fill(scratch.buffer.begin(), scratch.buffer.end(), 0); + pb_ostream_t dstream = pb_ostream_from_buffer(scratch.buffer.data(), scratch.buffer.size()); if (!pb_encode(&dstream, meshtastic_Data_fields, &decoded)) { return false; @@ -2955,8 +3367,8 @@ bool MeshtasticRadioAdapter::injectMqttEnvelope(const meshtastic_MeshPacket& pac size_t psk_len = 0; const uint8_t* psk = selectKey(config_, channel_index, &psk_len); - size_t rebuilt_size = sizeof(wire_buffer); - if (!::chat::meshtastic::buildWirePacket(data_buffer, dstream.bytes_written, + size_t rebuilt_size = scratch.wire.size(); + if (!::chat::meshtastic::buildWirePacket(scratch.buffer.data(), dstream.bytes_written, packet.from, packet.id, packet.to, channel_hash, packet.hop_limit, packet.want_ack, psk, psk_len, wire_buffer, &rebuilt_size)) @@ -2989,28 +3401,28 @@ bool MeshtasticRadioAdapter::queueMqttProxyPublish(const meshtastic_MeshPacket& return false; } - uint8_t env_buf[435] = {}; - meshtastic_MeshPacket packet_copy = packet; + auto& scratch = mqtt_scratch_; + std::memset(&scratch.proxy, 0, sizeof(scratch.proxy)); std::string node_id = mqttNodeIdString(); meshtastic_ServiceEnvelope env = meshtastic_ServiceEnvelope_init_zero; - env.packet = &packet_copy; + env.packet = const_cast(&packet); env.channel_id = const_cast(channel_id); env.gateway_id = const_cast(node_id.c_str()); - pb_ostream_t estream = pb_ostream_from_buffer(env_buf, sizeof(env_buf)); + pb_ostream_t estream = pb_ostream_from_buffer(scratch.proxy.payload_variant.data.bytes, + sizeof(scratch.proxy.payload_variant.data.bytes)); if (!pb_encode(&estream, meshtastic_ServiceEnvelope_fields, &env)) { return false; } - meshtastic_MqttClientProxyMessage proxy = meshtastic_MqttClientProxyMessage_init_zero; + meshtastic_MqttClientProxyMessage& proxy = scratch.proxy; proxy.which_payload_variant = meshtastic_MqttClientProxyMessage_data_tag; const std::string root = mqtt_proxy_settings_.root.empty() ? std::string("msh") : mqtt_proxy_settings_.root; const std::string topic = root + "/2/e/" + channel_id + "/" + node_id; std::strncpy(proxy.topic, topic.c_str(), sizeof(proxy.topic) - 1); proxy.topic[sizeof(proxy.topic) - 1] = '\0'; proxy.payload_variant.data.size = static_cast(estream.bytes_written); - std::memcpy(proxy.payload_variant.data.bytes, env_buf, estream.bytes_written); proxy.retained = false; while (mqtt_proxy_queue_.size() >= kMaxMqttProxyQueue) @@ -3018,6 +3430,14 @@ bool MeshtasticRadioAdapter::queueMqttProxyPublish(const meshtastic_MeshPacket& mqtt_proxy_queue_.pop(); } mqtt_proxy_queue_.push(proxy); + logMeshtasticRx("[gat562][mt][mqtt] queued topic=%s env=%u variant=%u port=%u q=%u\n", + proxy.topic, + static_cast(proxy.payload_variant.data.size), + static_cast(packet.which_payload_variant), + packet.which_payload_variant == meshtastic_MeshPacket_decoded_tag + ? static_cast(packet.decoded.portnum) + : 0U, + static_cast(mqtt_proxy_queue_.size())); return true; } @@ -3032,9 +3452,10 @@ bool MeshtasticRadioAdapter::queueMqttProxyPublishFromWire(const uint8_t* wire_d } ::chat::meshtastic::PacketHeaderWire header{}; - uint8_t payload[256] = {}; - size_t payload_size = sizeof(payload); - if (!::chat::meshtastic::parseWirePacket(wire_data, wire_size, &header, payload, &payload_size)) + auto& scratch = mqtt_scratch_; + std::fill(scratch.buffer.begin(), scratch.buffer.end(), 0); + size_t payload_size = scratch.buffer.size(); + if (!::chat::meshtastic::parseWirePacket(wire_data, wire_size, &header, scratch.buffer.data(), &payload_size)) { return false; } @@ -3054,12 +3475,12 @@ bool MeshtasticRadioAdapter::queueMqttProxyPublishFromWire(const uint8_t* wire_d if (mqtt_proxy_settings_.encryption_enabled) { - meshtastic_MeshPacket encrypted_packet = meshtastic_MeshPacket_init_zero; - if (!makeEncryptedPacketFromWire(wire_data, wire_size, &encrypted_packet)) + std::memset(&scratch.packet, 0, sizeof(scratch.packet)); + if (!makeEncryptedPacketFromWire(wire_data, wire_size, &scratch.packet)) { return false; } - return queueMqttProxyPublish(encrypted_packet, channel_id); + return queueMqttProxyPublish(scratch.packet, channel_id); } if (!decoded) @@ -3067,9 +3488,9 @@ bool MeshtasticRadioAdapter::queueMqttProxyPublishFromWire(const uint8_t* wire_d return false; } - meshtastic_MeshPacket decoded_packet = meshtastic_MeshPacket_init_zero; - fillDecodedPacketCommon(&decoded_packet, *decoded, header, channel_index); - return queueMqttProxyPublish(decoded_packet, channel_id); + std::memset(&scratch.packet, 0, sizeof(scratch.packet)); + fillDecodedPacketCommon(&scratch.packet, *decoded, header, channel_index); + return queueMqttProxyPublish(scratch.packet, channel_id); } } // namespace platform::nrf52::arduino_common::chat::meshtastic diff --git a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/node_store.cpp b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/node_store.cpp index d447b07f..09190058 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/node_store.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/node_store.cpp @@ -7,6 +7,7 @@ NodeStore::NodeStore() : blob_store_("/chat_nodes.bin"), core_(blob_store_) { + core_.setAutoSaveEnabled(false); } void NodeStore::begin() @@ -14,6 +15,11 @@ void NodeStore::begin() core_.begin(); } +void NodeStore::applyUpdate(uint32_t node_id, const ::chat::contacts::NodeUpdate& update) +{ + core_.applyUpdate(node_id, update); +} + void NodeStore::upsert(uint32_t node_id, const char* short_name, const char* long_name, uint32_t now_secs, float snr, float rssi, uint8_t protocol, uint8_t role, uint8_t hops_away, uint8_t hw_model, uint8_t channel) @@ -57,6 +63,11 @@ void NodeStore::clear() core_.clear(); } +bool NodeStore::flush() +{ + return core_.flush(); +} + void NodeStore::setProtectedNodeChecker(std::function checker) { core_.setProtectedNodeChecker(std::move(checker)); diff --git a/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp b/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp index 83785c5b..a2113408 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp @@ -2,6 +2,7 @@ #include +#include "sys/clock.h" #include #include #include @@ -39,7 +40,8 @@ void InternalFsStore::append(const ::chat::ChatMessage& msg) { storage.unread_count++; } - (void)saveToFs(); + markDirty(); + maybeSave(); } std::vector<::chat::ChatMessage> InternalFsStore::loadRecent(const ::chat::ConversationId& conv, size_t n) @@ -119,7 +121,8 @@ std::vector<::chat::ConversationMeta> InternalFsStore::loadConversationPage(size void InternalFsStore::setUnread(const ::chat::ConversationId& conv, int unread) { getConversationStorage(conv).unread_count = unread; - (void)saveToFs(); + markDirty(); + maybeSave(); } int InternalFsStore::getUnread(const ::chat::ConversationId& conv) const @@ -139,7 +142,8 @@ void InternalFsStore::clearConversation(const ::chat::ConversationId& conv) it->second.messages.clear(); it->second.unread_count = 0; conversations_.erase(it); - (void)saveToFs(); + markDirty(); + maybeSave(true); } void InternalFsStore::clearAll() @@ -147,6 +151,9 @@ void InternalFsStore::clearAll() conversations_.clear(); total_message_count_ = 0; next_sequence_ = 1; + dirty_ = false; + pending_write_count_ = 0; + dirty_since_ms_ = 0; if (ensureFs() && path_ && InternalFS.exists(path_)) { InternalFS.remove(path_); @@ -172,7 +179,8 @@ bool InternalFsStore::updateMessageStatus(::chat::MessageId msg_id, ::chat::Mess continue; } msg->status = status; - (void)saveToFs(); + markDirty(); + maybeSave(); return true; } } @@ -312,6 +320,10 @@ bool InternalFsStore::loadFromFs() { evictOldestMessage(); } + dirty_ = false; + pending_write_count_ = 0; + dirty_since_ms_ = 0; + last_save_ms_ = sys::millis_now(); return true; } @@ -403,7 +415,49 @@ bool InternalFsStore::saveToFs() const { InternalFS.remove(path_); } - return InternalFS.rename(temp_path.c_str(), path_); + const bool renamed = InternalFS.rename(temp_path.c_str(), path_); + if (renamed) + { + dirty_ = false; + pending_write_count_ = 0; + dirty_since_ms_ = 0; + last_save_ms_ = sys::millis_now(); + } + return renamed; +} + +void InternalFsStore::flush() +{ + maybeSave(true); +} + +void InternalFsStore::markDirty() +{ + if (!dirty_) + { + dirty_ = true; + dirty_since_ms_ = sys::millis_now(); + } + ++pending_write_count_; +} + +void InternalFsStore::maybeSave(bool force) +{ + if (!dirty_) + { + return; + } + + const uint32_t now_ms = sys::millis_now(); + const bool interval_elapsed = + (dirty_since_ms_ != 0) && ((now_ms - dirty_since_ms_) >= kSaveIntervalMs); + const bool too_many_pending = pending_write_count_ >= kMaxPendingWrites; + if (!force && !interval_elapsed && !too_many_pending) + { + return; + } + + (void)saveToFs(); } void InternalFsStore::evictOldestMessage() diff --git a/platform/nrf52/arduino_common/src/internal_fs_utils.cpp b/platform/nrf52/arduino_common/src/internal_fs_utils.cpp new file mode 100644 index 00000000..65c5eff4 --- /dev/null +++ b/platform/nrf52/arduino_common/src/internal_fs_utils.cpp @@ -0,0 +1,161 @@ +#include "platform/nrf52/arduino_common/internal_fs_utils.h" + +#include +#include + +namespace platform::nrf52::arduino_common::internal_fs +{ +namespace +{ + +constexpr const char* kDefaultLogTag = "[nrf52][fs]"; + +const char* resolveLogTag(const char* log_tag) +{ + return log_tag ? log_tag : kDefaultLogTag; +} + +void logLine(const char* log_tag, const char* message) +{ + if (!log_tag || !message) + { + return; + } + Serial.printf("%s %s\n", resolveLogTag(log_tag), message); +} + +void logPath(const char* log_tag, const char* message, const char* path) +{ + if (!log_tag || !message) + { + return; + } + Serial.printf("%s %s path=%s\n", + resolveLogTag(log_tag), + message, + path ? path : "?"); +} + +bool recoverByFormat(const char* log_tag) +{ + if (log_tag) + { + logLine(log_tag, "fs format recovery start"); + } + if (!InternalFS.format()) + { + logLine(log_tag, "fs format failed"); + return false; + } + if (!InternalFS.begin()) + { + logLine(log_tag, "fs remount after format failed"); + return false; + } + if (log_tag) + { + logLine(log_tag, "fs recovered by format"); + } + return true; +} + +} // namespace + +bool ensureMounted(bool allow_format_recovery, const char* log_tag) +{ + if (InternalFS.begin()) + { + return true; + } + + logLine(log_tag, "fs begin failed"); + return allow_format_recovery ? recoverByFormat(log_tag) : false; +} + +void removeIfExists(const char* path) +{ + if (path && InternalFS.exists(path)) + { + InternalFS.remove(path); + } +} + +bool openForOverwrite(const char* path, + File* out, + bool allow_format_recovery, + const char* log_tag) +{ + if (!out || !path) + { + return false; + } + + *out = File(InternalFS); + if (!ensureMounted(allow_format_recovery, log_tag)) + { + return false; + } + + *out = InternalFS.open(path, Adafruit_LittleFS_Namespace::FILE_O_WRITE); + if (*out) + { + return true; + } + + logPath(log_tag, "open for overwrite failed", path); + if (!allow_format_recovery || !recoverByFormat(log_tag)) + { + return false; + } + + *out = InternalFS.open(path, Adafruit_LittleFS_Namespace::FILE_O_WRITE); + if (!*out) + { + logPath(log_tag, "open for overwrite failed after recovery", path); + return false; + } + return true; +} + +bool rewindForOverwrite(File& file) +{ + return file && file.seek(0); +} + +bool truncateAfterWrite(File& file, uint32_t final_size) +{ + return file && file.truncate(final_size); +} + +uint32_t accumulateBytes(File dir) +{ + uint32_t total = 0; + if (!dir) + { + return total; + } + + dir.rewindDirectory(); + while (true) + { + File entry = dir.openNextFile(); + if (!entry) + { + break; + } + + if (entry.isDirectory()) + { + total += accumulateBytes(entry); + } + else + { + total += entry.size(); + } + entry.close(); + } + + return total; +} + +} // namespace platform::nrf52::arduino_common::internal_fs diff --git a/platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp b/platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp new file mode 100644 index 00000000..b7b2171d --- /dev/null +++ b/platform/nrf52/arduino_common/src/platform_ui_screen_runtime.cpp @@ -0,0 +1,95 @@ +#include "platform/ui/screen_runtime.h" + +#include "platform/ui/settings_store.h" + +namespace platform::ui::screen +{ +namespace +{ + +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kScreenTimeoutKey = "screen_timeout"; +constexpr uint32_t kScreenTimeoutDefaultMs = 30000UL; +constexpr uint32_t kScreenTimeoutMinMs = 15000UL; +constexpr uint32_t kScreenTimeoutMaxMs = 300000UL; + +bool s_sleep_disabled = false; + +uint32_t normalize_timeout_ms(uint32_t timeout_ms) +{ + if (timeout_ms < kScreenTimeoutMinMs) + { + return kScreenTimeoutDefaultMs; + } + if (timeout_ms > kScreenTimeoutMaxMs) + { + return kScreenTimeoutMaxMs; + } + return timeout_ms; +} + +} // namespace + +uint32_t clamp_timeout_ms(uint32_t timeout_ms) +{ + return normalize_timeout_ms(timeout_ms); +} + +uint32_t timeout_ms() +{ + return normalize_timeout_ms( + ::platform::ui::settings_store::get_uint(kSettingsNs, kScreenTimeoutKey, kScreenTimeoutDefaultMs)); +} + +uint16_t timeout_secs() +{ + return static_cast(timeout_ms() / 1000U); +} + +void set_timeout_ms(uint32_t timeout_ms) +{ + ::platform::ui::settings_store::put_uint(kSettingsNs, kScreenTimeoutKey, normalize_timeout_ms(timeout_ms)); +} + +void init(const Hooks&) +{ +} + +bool is_sleeping() +{ + return false; +} + +bool is_sleep_disabled() +{ + return s_sleep_disabled; +} + +bool is_saver_active() +{ + return false; +} + +void wake_saver() +{ +} + +void enter_from_saver() +{ +} + +void update_user_activity() +{ +} + +void disable_sleep() +{ + s_sleep_disabled = true; +} + +void enable_sleep() +{ + s_sleep_disabled = false; +} + +} // namespace platform::ui::screen diff --git a/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp b/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp index dcd3a6fe..e124d6c9 100644 --- a/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp +++ b/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp @@ -1,7 +1,10 @@ +#include "platform/nrf52/arduino_common/internal_fs_utils.h" #include "platform/ui/settings_store.h" +#include #include +#include #include #include #include @@ -12,10 +15,9 @@ namespace { using Adafruit_LittleFS_Namespace::FILE_O_READ; -using Adafruit_LittleFS_Namespace::FILE_O_WRITE; constexpr const char* kSettingsPath = "/ui_settings.bin"; -constexpr const char* kSettingsTempPath = "/ui_settings.bin.tmp"; +constexpr const char* kLogTag = "[nrf52][ui_settings]"; constexpr uint32_t kMagic = 0x55535447UL; // USTG constexpr uint16_t kVersion = 1; @@ -88,23 +90,62 @@ void clearAllStores() blobStore().clear(); } -bool ensureFs() -{ - return InternalFS.begin(); -} - template bool writePod(Adafruit_LittleFS_Namespace::File& file, const T& value) { return file.write(reinterpret_cast(&value), sizeof(T)) == sizeof(T); } +bool writeBytes(Adafruit_LittleFS_Namespace::File& file, const uint8_t* data, std::size_t len) +{ + if (!data && len != 0) + { + return false; + } + + constexpr std::size_t kChunkSize = 128; + std::size_t offset = 0; + while (offset < len) + { + const std::size_t chunk = std::min(kChunkSize, len - offset); + const std::size_t written = file.write(data + offset, chunk); + if (written != chunk) + { + return false; + } + offset += written; + } + return true; +} + template bool readPod(Adafruit_LittleFS_Namespace::File& file, T* value) { return value && file.read(value, sizeof(T)) == sizeof(T); } +bool readBytes(Adafruit_LittleFS_Namespace::File& file, uint8_t* data, std::size_t len) +{ + if (!data && len != 0) + { + return false; + } + + constexpr std::size_t kChunkSize = 128; + std::size_t offset = 0; + while (offset < len) + { + const std::size_t chunk = std::min(kChunkSize, len - offset); + const int read = file.read(data + offset, static_cast(chunk)); + if (read != static_cast(chunk)) + { + return false; + } + offset += static_cast(read); + } + return true; +} + bool writeRecordHeader(Adafruit_LittleFS_Namespace::File& file, ValueType type, const std::string& key, @@ -115,29 +156,40 @@ bool writeRecordHeader(Adafruit_LittleFS_Namespace::File& file, header.key_len = static_cast(key.size()); header.value_len = value_len; return writePod(file, header) && - (key.empty() || file.write(key.data(), key.size()) == key.size()); + (key.empty() || writeBytes(file, reinterpret_cast(key.data()), key.size())); } bool saveToFs() { - if (!ensureFs()) + if (!::platform::nrf52::arduino_common::internal_fs::ensureMounted(false, kLogTag)) { return false; } - auto file = InternalFS.open(kSettingsTempPath, FILE_O_WRITE); - if (!file) + Adafruit_LittleFS_Namespace::File file(InternalFS); + if (!::platform::nrf52::arduino_common::internal_fs::openForOverwrite(kSettingsPath, &file, false, kLogTag)) { + Serial.printf("%s open failed path=%s\n", kLogTag, kSettingsPath); + return false; + } + if (!::platform::nrf52::arduino_common::internal_fs::rewindForOverwrite(file)) + { + file.close(); + Serial.printf("%s seek failed path=%s\n", kLogTag, kSettingsPath); return false; } const uint32_t record_count = static_cast( intStore().size() + boolStore().size() + uintStore().size() + blobStore().size()); + uint32_t final_size = static_cast(sizeof(FileHeader)); FileHeader header{}; header.record_count = record_count; if (!writePod(file, header)) { file.close(); + Serial.printf("%s header write failed records=%lu\n", + kLogTag, + static_cast(record_count)); return false; } @@ -145,53 +197,68 @@ bool saveToFs() { const int32_t value = entry.second; if (!writeRecordHeader(file, ValueType::Int, entry.first, sizeof(value)) || - file.write(reinterpret_cast(&value), sizeof(value)) != sizeof(value)) + !writeBytes(file, reinterpret_cast(&value), sizeof(value))) { file.close(); + Serial.printf("%s int write failed key=%s\n", kLogTag, entry.first.c_str()); return false; } + final_size += static_cast(sizeof(RecordHeader) + entry.first.size() + sizeof(value)); } for (const auto& entry : boolStore()) { const uint8_t value = entry.second ? 1U : 0U; if (!writeRecordHeader(file, ValueType::Bool, entry.first, sizeof(value)) || - file.write(&value, sizeof(value)) != sizeof(value)) + !writeBytes(file, &value, sizeof(value))) { file.close(); + Serial.printf("%s bool write failed key=%s\n", kLogTag, entry.first.c_str()); return false; } + final_size += static_cast(sizeof(RecordHeader) + entry.first.size() + sizeof(value)); } for (const auto& entry : uintStore()) { const uint32_t value = entry.second; if (!writeRecordHeader(file, ValueType::Uint, entry.first, sizeof(value)) || - file.write(reinterpret_cast(&value), sizeof(value)) != sizeof(value)) + !writeBytes(file, reinterpret_cast(&value), sizeof(value))) { file.close(); + Serial.printf("%s uint write failed key=%s\n", kLogTag, entry.first.c_str()); return false; } + final_size += static_cast(sizeof(RecordHeader) + entry.first.size() + sizeof(value)); } for (const auto& entry : blobStore()) { if (!writeRecordHeader(file, ValueType::Blob, entry.first, static_cast(entry.second.size())) || - (!entry.second.empty() && file.write(entry.second.data(), entry.second.size()) != entry.second.size())) + (!entry.second.empty() && !writeBytes(file, entry.second.data(), entry.second.size()))) { file.close(); + Serial.printf("%s blob write failed key=%s len=%lu\n", + kLogTag, + entry.first.c_str(), + static_cast(entry.second.size())); return false; } + final_size += static_cast(sizeof(RecordHeader) + entry.first.size() + entry.second.size()); } + const bool trunc_ok = ::platform::nrf52::arduino_common::internal_fs::truncateAfterWrite(file, final_size); file.flush(); file.close(); - if (InternalFS.exists(kSettingsPath)) + if (!trunc_ok) { - InternalFS.remove(kSettingsPath); + Serial.printf("%s truncate failed path=%s size=%lu\n", + kLogTag, + kSettingsPath, + static_cast(final_size)); } - return InternalFS.rename(kSettingsTempPath, kSettingsPath); + return trunc_ok; } void ensureLoaded() @@ -202,7 +269,7 @@ void ensureLoaded() } clearAllStores(); - if (!ensureFs()) + if (!::platform::nrf52::arduino_common::internal_fs::ensureMounted(false, kLogTag)) { return; } @@ -241,7 +308,8 @@ void ensureLoaded() } std::string key(rec.key_len, '\0'); - if (rec.key_len > 0 && file.read(&key[0], rec.key_len) != rec.key_len) + if (rec.key_len > 0 && + !readBytes(file, reinterpret_cast(&key[0]), rec.key_len)) { file.close(); return; @@ -285,7 +353,7 @@ void ensureLoaded() case ValueType::Blob: { std::vector value(rec.value_len, 0); - if (rec.value_len > 0 && file.read(value.data(), rec.value_len) != rec.value_len) + if (rec.value_len > 0 && !readBytes(file, value.data(), rec.value_len)) { file.close(); return; @@ -296,7 +364,7 @@ void ensureLoaded() default: { std::vector skip(rec.value_len, 0); - if (rec.value_len > 0 && file.read(skip.data(), rec.value_len) != rec.value_len) + if (rec.value_len > 0 && !readBytes(file, skip.data(), rec.value_len)) { file.close(); return; @@ -359,7 +427,15 @@ bool put_blob(const char* ns, const char* key, const void* data, std::size_t len } ensureLoaded(); auto& blob = blobStore()[makeScopedKey(ns, key)]; - blob.assign(static_cast(data), static_cast(data) + len); + if (len == 0) + { + blob.clear(); + } + else + { + const auto* bytes = static_cast(data); + blob.assign(bytes, bytes + len); + } return saveToFs(); } diff --git a/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini b/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini index 5514fe7a..e77b0d40 100644 --- a/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini +++ b/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini @@ -10,6 +10,7 @@ build_flags = -DNRF52840_XXAA -DS140 -DGAT562_MESH_EVB_PRO + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=30 -DGAT562_NO_TEAM=1 -DGAT562_NO_HOSTLINK=1 -DGAT562_NO_SD=1 @@ -54,14 +55,6 @@ build_flags = -I${PROJECT_DIR}/modules/core_gps/include -I${PROJECT_DIR}/modules/ui_mono_128x64/include -I${PROJECT_DIR}/modules/ui_shared/include - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/Adafruit_TinyUSB_Arduino/src - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/SPI - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/Wire - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/Bluefruit52Lib/src - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/Bluefruit52Lib/src/services - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/Adafruit_nRFCrypto/src - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/Adafruit_LittleFS/src - -I${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries/InternalFileSytem/src build_src_filter = +<*> - @@ -74,7 +67,6 @@ lib_extra_dirs = ${PROJECT_DIR}/boards ${PROJECT_DIR}/platform/nrf52 ${PROJECT_DIR}/third_party - ${platformio.packages_dir}/framework-arduinoadafruitnrf52/libraries lib_deps = jgromes/RadioLib @ 7.4.0 mikalhart/TinyGPSPlus @ 1.0.3 diff --git a/variants/lilygo_tlora_pager/envs/tlora_pager.ini b/variants/lilygo_tlora_pager/envs/tlora_pager.ini index 110d865a..636a8374 100644 --- a/variants/lilygo_tlora_pager/envs/tlora_pager.ini +++ b/variants/lilygo_tlora_pager/envs/tlora_pager.ini @@ -13,6 +13,7 @@ build_flags = -D SCREEN_HEIGHT=222 -D ARDUINO_T_LORA_PAGER -D ARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 -I variants/lilygo_tlora_pager lib_deps = ${arduino_base.lib_deps} @@ -36,6 +37,7 @@ build_flags = -D SCREEN_HEIGHT=222 -D ARDUINO_T_LORA_PAGER -D ARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 -D MESHCORE_LOG_ENABLE=1 -D LORA_LOG_ENABLE=1 -D APP_EVENT_LOG_ENABLE=1 @@ -61,6 +63,7 @@ build_flags = -D SCREEN_HEIGHT=222 -D ARDUINO_T_LORA_PAGER -D ARDUINO_LILYGO_LORA_SX1280 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=13 -I variants/lilygo_tlora_pager lib_deps = ${arduino_base.lib_deps} @@ -83,6 +86,7 @@ build_flags = -D SCREEN_HEIGHT=222 -D ARDUINO_T_LORA_PAGER -D ARDUINO_LILYGO_LORA_SX1280 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=13 -D MESHCORE_LOG_ENABLE=1 -D LORA_LOG_ENABLE=1 -D APP_EVENT_LOG_ENABLE=1 diff --git a/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini b/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini index 9af15561..ae1b14a9 100644 --- a/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini +++ b/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini @@ -11,6 +11,7 @@ build_flags = -DBOARD_HAS_PSRAM=1 -DARDUINO_T_WATCH_S3 -DARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 -DDISPLAY_DRIVER_ST7789V2 -DSCREEN_WIDTH=240 -DSCREEN_HEIGHT=240 @@ -35,6 +36,7 @@ build_flags = -DBOARD_HAS_PSRAM=1 -DARDUINO_T_WATCH_S3 -DARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 -DDISPLAY_DRIVER_ST7789V2 -DSCREEN_WIDTH=240 -DSCREEN_HEIGHT=240 diff --git a/variants/tdeck/envs/tdeck.ini b/variants/tdeck/envs/tdeck.ini index 96693d28..429b4ddd 100644 --- a/variants/tdeck/envs/tdeck.ini +++ b/variants/tdeck/envs/tdeck.ini @@ -10,6 +10,7 @@ build_flags = -DBOARD_HAS_PSRAM=1 -DARDUINO_T_DECK -DARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 -DARDUINO_USB_CDC_ON_BOOT=1 -DDISPLAY_DRIVER_ST7789 -DSCREEN_WIDTH=320 @@ -32,6 +33,7 @@ build_flags = -DBOARD_HAS_PSRAM=1 -DARDUINO_T_DECK -DARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 -DARDUINO_USB_CDC_ON_BOOT=1 -DDISPLAY_DRIVER_ST7789 -DSCREEN_WIDTH=320 diff --git a/variants/tdeck_pro/envs/tdeck_pro.ini b/variants/tdeck_pro/envs/tdeck_pro.ini new file mode 100644 index 00000000..9b36e30f --- /dev/null +++ b/variants/tdeck_pro/envs/tdeck_pro.ini @@ -0,0 +1,76 @@ +[env:tdeck_pro_a7682e] +extends = arduino_base +board = T-Deck-Pro +upload_speed = 921600 +build_flags = + ${arduino_base.build_flags} + -DBOARD_HAS_PSRAM=1 + -DARDUINO_T_DECK_PRO + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 + -DSCREEN_WIDTH=240 + -DSCREEN_HEIGHT=320 + -DSENSOR_INT=21 + -DGPS_TX=43 + -DGPS_RX=44 + -DLORA_CS=3 + -DLORA_BUSY=6 + -DLORA_RST=4 + -DLORA_IRQ=5 + -DSD_CS=48 + -DDISP_CS=34 + -DDISP_DC=35 + -DDISP_RST=-1 + -DDISP_BUSY=37 + -DBOARD_TOUCH_INT=12 + -DUSING_INPUT_DEV_TOUCHPAD + -DUSING_INPUT_DEV_KEYBOARD + -DTRAIL_MATE_TDECK_PRO=1 + -DTRAIL_MATE_TDECK_PRO_A7682E=1 +lib_deps = + ${arduino_base.lib_deps} + zinggjm/GxEPD2 @ 1.5.9 + adafruit/Adafruit TCA8418 @ ^1.0.2 + adafruit/Adafruit BusIO @ ^1.17.0 + mikalhart/TinyGPSPlus @ 1.0.3 + +[env:tdeck_pro_pcm512a] +extends = arduino_base +board = T-Deck-Pro +upload_speed = 921600 +build_flags = + ${arduino_base.build_flags} + -DBOARD_HAS_PSRAM=1 + -DARDUINO_T_DECK_PRO + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_LILYGO_LORA_SX1262 + -DTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 + -DSCREEN_WIDTH=240 + -DSCREEN_HEIGHT=320 + -DSENSOR_INT=21 + -DGPS_TX=43 + -DGPS_RX=44 + -DLORA_CS=3 + -DLORA_BUSY=6 + -DLORA_RST=4 + -DLORA_IRQ=5 + -DSD_CS=48 + -DDISP_CS=34 + -DDISP_DC=35 + -DDISP_RST=-1 + -DDISP_BUSY=37 + -DBOARD_TOUCH_INT=12 + -DDAC_I2S_BCK=7 + -DDAC_I2S_WS=9 + -DDAC_I2S_DOUT=8 + -DUSING_INPUT_DEV_TOUCHPAD + -DUSING_INPUT_DEV_KEYBOARD + -DTRAIL_MATE_TDECK_PRO=1 + -DTRAIL_MATE_TDECK_PRO_PCM512A=1 +lib_deps = + ${arduino_base.lib_deps} + zinggjm/GxEPD2 @ 1.5.9 + adafruit/Adafruit TCA8418 @ ^1.0.2 + adafruit/Adafruit BusIO @ ^1.17.0 + mikalhart/TinyGPSPlus @ 1.0.3