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 48d95067..40e23b06 100644 Binary files a/CHANGELOG.md and b/CHANGELOG.md differ diff --git a/README.md b/README.md index a6f86f20..aceff073 100644 Binary files a/README.md and b/README.md differ diff --git a/README_CN.md b/README_CN.md index 7e3e0f3d..6389c498 100644 Binary files a/README_CN.md and b/README_CN.md differ 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 00000000..16a47005 Binary files /dev/null and b/images/aprs.png differ diff --git a/modules/core_chat/include/chat/ble/meshtastic_defaults.h b/modules/core_chat/include/chat/ble/meshtastic_defaults.h new file mode 100644 index 00000000..162c772b --- /dev/null +++ b/modules/core_chat/include/chat/ble/meshtastic_defaults.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +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