diff --git a/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp index a3314c7a..ca3c3ae4 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp @@ -1005,6 +1005,31 @@ std::unique_ptr createIdfChatStore( return std::unique_ptr(new chat::RamStore()); } +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) +constexpr std::size_t kDeferredRadioApplySlotCount = 3U; +constexpr uint8_t kDeferredRadioApplyMesh = 1U << 0; +constexpr uint8_t kDeferredRadioApplyUserInfo = 1U << 1; +constexpr uint8_t kDeferredRadioApplyNetworkLimits = 1U << 2; +constexpr uint8_t kDeferredRadioApplyPrivacy = 1U << 3; + +// This object is embedded in IdfAppFacadeRuntime, which is allocated in PSRAM +// by createRuntime(). Keeping three fixed slots avoids a heap-backed queue, +// avoids a large task-stack copy, and lets the UI writer, pending request, and +// app-loop consumer remain disjoint while a radio apply is in progress. +struct DeferredRadioApplySnapshot +{ + chat::MeshProtocol protocol{}; + chat::MeshConfig mesh_config{}; + char long_name[32] = {}; + char short_name[16] = {}; + uint8_t net_duty_cycle = 0; + uint8_t net_channel_util = 0; + uint8_t privacy_encrypt_mode = 0; + uint8_t flags = 0; +}; +#endif + class IdfAppFacadeRuntime final : public app::IAppFacade { public: @@ -1183,6 +1208,15 @@ class IdfAppFacadeRuntime final : public app::IAppFacade void applyMeshConfig() override { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + if (initialized_) + { + queueDeferredRadioApply(kDeferredRadioApplyMesh); + return; + } + applyMeshConfigNow(config_.mesh_protocol, config_.activeMeshConfig()); +#else normalizeIdfAppConfig(config_); syncReticulumGroupConfig(config_); if (!mesh_router_.hasBackend() || @@ -1204,10 +1238,19 @@ class IdfAppFacadeRuntime final : public app::IAppFacade { board_->applyRadioConfig(config_.mesh_protocol, config_.activeMeshConfig()); } +#endif } void applyUserInfo() override { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + if (initialized_) + { + queueDeferredRadioApply(kDeferredRadioApplyUserInfo); + return; + } +#endif char long_name[sizeof(config_.node_name)] = {}; char short_name[sizeof(config_.short_name)] = {}; getEffectiveUserInfo(long_name, sizeof(long_name), short_name, sizeof(short_name)); @@ -1218,11 +1261,27 @@ class IdfAppFacadeRuntime final : public app::IAppFacade void applyNetworkLimits() override { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + if (initialized_) + { + queueDeferredRadioApply(kDeferredRadioApplyNetworkLimits); + return; + } +#endif meshAdapter().setNetworkLimits(config_.net_duty_cycle, config_.net_channel_util); } void applyPrivacyConfig() override { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + if (initialized_) + { + queueDeferredRadioApply(kDeferredRadioApplyPrivacy); + return; + } +#endif meshAdapter().setPrivacyConfig(config_.privacy_encrypt_mode); } @@ -1267,6 +1326,25 @@ class IdfAppFacadeRuntime final : public app::IAppFacade return false; } +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + if (initialized_) + { + if (lora_board_ == nullptr) + { + return false; + } + config_.mesh_protocol = normalized; + syncReticulumGroupConfig(config_); + queueDeferredRadioApply(kDeferredRadioApplyMesh); + if (persist) + { + saveConfig(); + } + return true; + } +#endif + std::unique_ptr backend = createMeshBackend(normalized); if (!backend) @@ -1380,6 +1458,10 @@ class IdfAppFacadeRuntime final : public app::IAppFacade void updateCoreServices() override { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + flushDeferredRadioApplies(); +#endif flushConfigPersistence(persistenceNowMs()); if (!mesh_peer_directory_hydrated_ && platform::esp::idf_common::storage::consume_hydration_ready()) @@ -1466,6 +1548,284 @@ class IdfAppFacadeRuntime final : public app::IAppFacade return static_cast(esp_timer_get_time() / 1000ULL); } +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + void captureDeferredRadioApply(DeferredRadioApplySnapshot& snapshot, + uint8_t flags) + { + normalizeIdfAppConfig(config_); + syncReticulumGroupConfig(config_); + snapshot.protocol = config_.mesh_protocol; + snapshot.mesh_config = config_.activeMeshConfig(); + snapshot.net_duty_cycle = config_.net_duty_cycle ? 1U : 0U; + snapshot.net_channel_util = config_.net_channel_util; + snapshot.privacy_encrypt_mode = config_.privacy_encrypt_mode; + getEffectiveUserInfo(snapshot.long_name, + sizeof(snapshot.long_name), + snapshot.short_name, + sizeof(snapshot.short_name)); + snapshot.flags = flags; + } + + void queueDeferredRadioApply(uint8_t flags) + { + int8_t write_slot = -1; + uint8_t coalesced_flags = flags; + + portENTER_CRITICAL(&deferred_radio_apply_mux_); + if (deferred_radio_pending_slot_ >= 0) + { + coalesced_flags = static_cast( + coalesced_flags | + deferred_radio_apply_slots_[deferred_radio_pending_slot_].flags); + } + for (std::size_t index = 0; index < kDeferredRadioApplySlotCount; ++index) + { + const int8_t candidate = static_cast(index); + if (candidate != deferred_radio_active_slot_ && + candidate != deferred_radio_pending_slot_ && + candidate != deferred_radio_writing_slot_) + { + deferred_radio_writing_slot_ = candidate; + write_slot = candidate; + break; + } + } + portEXIT_CRITICAL(&deferred_radio_apply_mux_); + + if (write_slot < 0) + { + ESP_LOGE(kIdfConfigTag, + "radio apply request dropped flags=0x%02x slots exhausted", + static_cast(flags)); + return; + } + + DeferredRadioApplySnapshot& snapshot = + deferred_radio_apply_slots_[write_slot]; + captureDeferredRadioApply(snapshot, coalesced_flags); + + portENTER_CRITICAL(&deferred_radio_apply_mux_); + if (deferred_radio_pending_slot_ >= 0) + { + snapshot.flags = static_cast( + snapshot.flags | + deferred_radio_apply_slots_[deferred_radio_pending_slot_].flags); + } + deferred_radio_pending_slot_ = write_slot; + deferred_radio_writing_slot_ = -1; + portEXIT_CRITICAL(&deferred_radio_apply_mux_); + + ESP_LOGI(kIdfConfigTag, + "radio apply queued flags=0x%02x proto=%u slot=%d", + static_cast(snapshot.flags), + static_cast(snapshot.protocol), + static_cast(write_slot)); + } + + int8_t takeDeferredRadioApply() + { + portENTER_CRITICAL(&deferred_radio_apply_mux_); + if (deferred_radio_active_slot_ >= 0 || + deferred_radio_pending_slot_ < 0) + { + portEXIT_CRITICAL(&deferred_radio_apply_mux_); + return -1; + } + + const int8_t slot = deferred_radio_pending_slot_; + deferred_radio_pending_slot_ = -1; + deferred_radio_active_slot_ = slot; + portEXIT_CRITICAL(&deferred_radio_apply_mux_); + return slot; + } + + void releaseDeferredRadioApply(int8_t slot) + { + portENTER_CRITICAL(&deferred_radio_apply_mux_); + if (deferred_radio_active_slot_ == slot) + { + deferred_radio_active_slot_ = -1; + } + portEXIT_CRITICAL(&deferred_radio_apply_mux_); + } + + void retryDeferredRadioApply(int8_t slot) + { + portENTER_CRITICAL(&deferred_radio_apply_mux_); + if (deferred_radio_active_slot_ == slot) + { + if (deferred_radio_pending_slot_ >= 0) + { + deferred_radio_apply_slots_[deferred_radio_pending_slot_].flags = + static_cast( + deferred_radio_apply_slots_[deferred_radio_pending_slot_].flags | + deferred_radio_apply_slots_[slot].flags); + deferred_radio_active_slot_ = -1; + } + else + { + deferred_radio_pending_slot_ = slot; + deferred_radio_active_slot_ = -1; + } + } + portEXIT_CRITICAL(&deferred_radio_apply_mux_); + } + + bool switchMeshBackendNow(const DeferredRadioApplySnapshot& snapshot) + { + const chat::MeshProtocol normalized = + chat::infra::normalizeMeshProtocol(snapshot.protocol); + if (!chat::infra::isValidMeshProtocol(normalized) || + !idfSupportsMeshProtocol(normalized)) + { + ESP_LOGE(kIdfConfigTag, + "deferred mesh backend rejected proto=%u", + static_cast(snapshot.protocol)); + return false; + } + + std::unique_ptr backend = + createMeshBackend(normalized); + if (!backend) + { + ESP_LOGE(kIdfConfigTag, + "deferred mesh backend unavailable proto=%u", + static_cast(normalized)); + return false; + } + + backend->applyConfig(snapshot.mesh_config); + backend->setUserInfo(snapshot.long_name, snapshot.short_name); + backend->setNetworkLimits(snapshot.net_duty_cycle != 0, + snapshot.net_channel_util); + backend->setPrivacyConfig(snapshot.privacy_encrypt_mode); + + if (!mesh_router_.installBackend(normalized, std::move(backend))) + { + ESP_LOGE(kIdfConfigTag, + "deferred mesh backend install failed proto=%u", + static_cast(normalized)); + return false; + } + + mesh_adapter_ = &mesh_router_; + if (chat_service_) + { + chat_service_->setActiveProtocol(normalized); + } + return true; + } + + void applyDeferredRadioApply(const DeferredRadioApplySnapshot& snapshot) + { + const bool backend_switch_needed = + !mesh_router_.hasBackend() || + mesh_router_.backendProtocol() != snapshot.protocol; + bool backend_switched = false; + if (backend_switch_needed) + { + if (!switchMeshBackendNow(snapshot)) + { + return; + } + backend_switched = true; + } + + if ((snapshot.flags & kDeferredRadioApplyMesh) != 0U && + !backend_switched) + { + meshAdapter().applyConfig(snapshot.mesh_config); + } + if (board_ && + (backend_switched || + (snapshot.flags & kDeferredRadioApplyMesh) != 0U)) + { + board_->applyRadioConfig(snapshot.protocol, snapshot.mesh_config); + } + if ((snapshot.flags & kDeferredRadioApplyUserInfo) != 0U && + !backend_switched) + { + meshAdapter().setUserInfo(snapshot.long_name, snapshot.short_name); + } + if ((snapshot.flags & kDeferredRadioApplyNetworkLimits) != 0U && + !backend_switched) + { + meshAdapter().setNetworkLimits(snapshot.net_duty_cycle != 0, + snapshot.net_channel_util); + } + if ((snapshot.flags & kDeferredRadioApplyPrivacy) != 0U && + !backend_switched) + { + meshAdapter().setPrivacyConfig(snapshot.privacy_encrypt_mode); + } + } + + void flushDeferredRadioApplies() + { + const int8_t slot = takeDeferredRadioApply(); + if (slot < 0) + { + return; + } + + DeferredRadioApplySnapshot& snapshot = + deferred_radio_apply_slots_[slot]; + const bool must_pause_radio_tasks = background_tasks_started_; + const bool radio_tasks_paused = + !must_pause_radio_tasks || app::AppTasks::pauseRadioTasks(); + if (!radio_tasks_paused) + { + ESP_LOGW(kIdfConfigTag, + "radio apply deferred again flags=0x%02x proto=%u reason=task_quiesce", + static_cast(snapshot.flags), + static_cast(snapshot.protocol)); + retryDeferredRadioApply(slot); + return; + } + + ESP_LOGI(kIdfConfigTag, + "radio apply begin flags=0x%02x proto=%u paused=%u", + static_cast(snapshot.flags), + static_cast(snapshot.protocol), + must_pause_radio_tasks ? 1U : 0U); + applyDeferredRadioApply(snapshot); + if (must_pause_radio_tasks) + { + app::AppTasks::resumeRadioTasks(); + } + ESP_LOGI(kIdfConfigTag, + "radio apply complete flags=0x%02x proto=%u", + static_cast(snapshot.flags), + static_cast(snapshot.protocol)); + releaseDeferredRadioApply(slot); + } + + void applyMeshConfigNow(chat::MeshProtocol protocol, + const chat::MeshConfig& mesh_config) + { + normalizeIdfAppConfig(config_); + syncReticulumGroupConfig(config_); + if (!mesh_router_.hasBackend() || + mesh_router_.backendProtocol() != protocol) + { + if (!switchMeshProtocol(protocol, false)) + { + ESP_LOGE(kIdfConfigTag, + "mesh backend switch failed proto=%u", + static_cast(protocol)); + return; + } + return; + } + meshAdapter().applyConfig(mesh_config); + if (board_) + { + board_->applyRadioConfig(protocol, mesh_config); + } + } +#endif + void flushConfigPersistence(uint32_t now_ms) { app::ConfigPersistenceWork work{}; @@ -1827,6 +2187,15 @@ class IdfAppFacadeRuntime final : public app::IAppFacade chat::ui::IChatUiRuntime* chat_ui_runtime_ = nullptr; bool mesh_peer_directory_ready_ = false; bool background_tasks_started_ = false; +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + portMUX_TYPE deferred_radio_apply_mux_ = portMUX_INITIALIZER_UNLOCKED; + DeferredRadioApplySnapshot + deferred_radio_apply_slots_[kDeferredRadioApplySlotCount]{}; + int8_t deferred_radio_active_slot_ = -1; + int8_t deferred_radio_pending_slot_ = -1; + int8_t deferred_radio_writing_slot_ = -1; +#endif }; IdfAppFacadeRuntime* s_runtime = nullptr; @@ -1880,19 +2249,18 @@ bool initialize(const platform::esp::boards::AppContextInitHandles& handles, app::bindAppFacade(*s_runtime); s_runtime->startDeferredStorage(); - if (!s_runtime->waitForInitialStorageHydration()) - { - ESP_LOGE(config.log_tag, - "initial storage hydration did not reach a ready state for %s", - config.target_name); - return false; - } if (!s_runtime->startBackgroundTasks()) { ESP_LOGE(config.log_tag, "IDF shared ESP background tasks unavailable for %s; radio TX/RX remains disabled", config.target_name); } + if (!s_runtime->waitForInitialStorageHydration()) + { + ESP_LOGE(config.log_tag, + "initial storage hydration did not reach a ready state for %s; keeping AppFacade and radio tasks active while storage-backed data remains unavailable", + config.target_name); + } ESP_LOGI(config.log_tag, "IDF AppFacade runtime bound for %s self=%08lX mesh_backend=%s", config.target_name, diff --git a/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp b/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp index 94aa1df5..6ad2f946 100644 --- a/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp +++ b/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp @@ -303,6 +303,149 @@ int main(int argc, char** argv) assert(!contains(t_display_p4_runtime, "const uint8_t finger_count = response[0];")); assert(contains(t_display_p4_runtime, "ESP_LOGI(kTag, \"touch press x=%ld y=%ld\"")); + assert(contains(t_display_p4_runtime, "dpi_cfg.num_fbs = 3;")); + assert(contains(t_display_p4_runtime, "P4DsiRotatingPresenter s_rotating_presenter;")); + assert(contains(t_display_p4_runtime, "s_rotating_presenter.init(s_panel,")); + assert(!contains(t_display_p4_runtime, "lvgl_port_add_disp_dsi")); + assert(!contains(t_display_p4_runtime, "avoid_tearing = false")); + assert(!contains(t_display_p4_runtime, "driver/ppa.h")); + assert(!contains(t_display_p4_runtime, "partial_ppa_display_flush_cb")); + + const std::string p4_dsi_presenter = read_file( + repo_root / + "platform/esp/idf_components/t_display_p4/p4_dsi_rotating_presenter.cpp"); + assert(contains(p4_dsi_presenter, "esp_lcd_dpi_panel_get_frame_buffer(panel_,")); + assert(contains(p4_dsi_presenter, "LV_DISPLAY_RENDER_MODE_FULL")); + assert(contains(p4_dsi_presenter, "front_buffer_.store(first_scanout")); + assert(contains(p4_dsi_presenter, "esp_lcd_panel_draw_bitmap(panel_,")); + assert(contains(p4_dsi_presenter, "awaiting_refresh_.store(true")); + assert(contains(p4_dsi_presenter, "lv_display_flush_ready(presenter->display_)")); + assert(contains(p4_dsi_presenter, "lv_draw_sw_rotate(")); + assert(!contains(p4_dsi_presenter, "driver/ppa.h")); + assert(!contains(p4_dsi_presenter, "ppa_do_scale_rotate_mirror")); + assert(contains(t_display_p4_tft_defaults, + "# CONFIG_LVGL_PORT_ENABLE_PPA is not set")); + assert(contains(t_display_p4_amoled_defaults, + "# CONFIG_LVGL_PORT_ENABLE_PPA is not set")); + + const std::string idf_sd_runtime = read_file( + repo_root / "platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp"); + assert(contains(idf_sd_runtime, + "defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) ||")); + assert(contains(idf_sd_runtime, "bool canUseMultiSectorDma")); + assert(contains(idf_sd_runtime, + "sdmmc_read_sectors(card_, dst, sector, ns)")); + assert(contains(idf_sd_runtime, + "sdmmc_write_sectors(card_, src, sector, ns)")); + assert(contains(idf_sd_runtime, + "card_->host.check_buffer_alignment(")); + assert(contains(idf_sd_runtime, "sector_count > (SIZE_MAX / kSdSectorSize)")); + + assert(contains(idf_app_facade, + "constexpr std::size_t kDeferredRadioApplySlotCount = 3U;")); + assert(contains(idf_app_facade, + "TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) ||")); + assert(contains(idf_app_facade, + "void queueDeferredRadioApply(uint8_t flags)")); + assert(contains(idf_app_facade, + "void flushDeferredRadioApplies()")); + assert(contains(idf_app_facade, + "app::AppTasks::pauseRadioTasks()")); + assert(contains(idf_app_facade, + "app::AppTasks::resumeRadioTasks();")); + assert(contains(idf_app_facade, + "deferred_radio_apply_slots_[kDeferredRadioApplySlotCount]{};")); + assert(contains(t_display_p4_runtime, "constexpr uint8_t kXl9555TMixRfEnableMask")); + assert(contains(t_display_p4_runtime, "constexpr uint8_t kXl9555TMixRfSwitch0Mask")); + assert(contains(t_display_p4_runtime, "constexpr uint8_t kXl9555TMixRfSwitch1Mask")); + assert(contains(t_display_p4_runtime, "constexpr uint8_t kXl9555Led1Mask")); + assert(contains(t_display_p4_runtime, "constexpr uint8_t kXl9555Led2Mask")); + assert(contains(t_display_p4_runtime, "constexpr uint8_t kXl9555Led3Mask")); + assert(contains(t_display_p4_runtime, "kXl9555KeyboardOutputMask")); + assert(contains(t_display_p4_runtime, "kXl9555KeyboardIdleHighMask")); + assert(contains(t_display_p4_runtime, "bool configure_keyboard_expander_outputs()")); + assert(contains(t_display_p4_runtime, + "config &= static_cast(~kXl9555KeyboardOutputMask);")); + assert(contains(t_display_p4_runtime, + "output = static_cast(preserved_output | " + "kXl9555KeyboardIdleHighMask);")); + assert(contains(t_display_p4_runtime, + "lv_indev_set_group(s_keyboard_indev, default_group);")); + assert(contains(t_display_p4_runtime, + "constexpr uint32_t kKeyboardPollFallbackIntervalMs = 30;")); + assert(contains(t_display_p4_runtime, + "constexpr uint32_t kKeyboardAttachRecoveryCooldownMs = 30000;")); + assert(contains(t_display_p4_runtime, + "constexpr bool kKeyboardAttachRecoveryAutoEnabled = false;")); + assert(contains(t_display_p4_runtime, + "constexpr uint8_t kKeyboardAttachRecoveryProbeCount = 3;")); + assert(contains(t_display_p4_runtime, "s_keyboard_last_poll_ticks = 0;")); + assert(contains(t_display_p4_runtime, + "T-Display-P4 keyboard interrupt unavailable; using %lums poll fallback")); + assert(contains(t_display_p4_runtime, "bool s_keyboard_i2c_pins_swapped = false;")); + assert(contains(t_display_p4_runtime, "int keyboard_sda_pin()")); + assert(contains(t_display_p4_runtime, "int keyboard_scl_pin()")); + assert(contains(t_display_p4_runtime, "keyboard_recover_i2c_bus(\"probe_swapped\")")); + assert(contains(t_display_p4_runtime, "keyboard_recover_i2c_bus(\"probe_restore\")")); + assert(contains(t_display_p4_runtime, + "T-Display-P4 keyboard I2C responded only with swapped SDA/SCL")); + assert(contains(t_display_p4_runtime, "i2c_swap=%d swapped_probe=%d")); + assert(contains(t_display_p4_runtime, + "bool keyboard_probe_needs_attach_power_recovery")); + assert(contains(t_display_p4_runtime, + "T-Display-P4 keyboard attach recovery attempt=%lu")); + assert(contains(t_display_p4_runtime, + "recoverExternal3v3ForKeyboardAttach(kKeyboardAttachRecoveryRailOffMs")); + assert(contains(t_display_p4_runtime, + "monitor_power_recovery")); + assert(contains(t_display_p4_runtime, + "std::max(1, pdMS_TO_TICKS(kKeyboardPollFallbackIntervalMs))")); + assert(contains(t_display_p4_runtime, + "poll_fallback_ms=%lu")); + assert(contains(t_display_p4_runtime, + "constexpr std::size_t kKeyboardEventQueueCapacity = 16;")); + assert(contains(t_display_p4_runtime, + "std::array " + "s_keyboard_event_queue")); + assert(contains(t_display_p4_runtime, "bool drain_keyboard_fifo_locked()")); + assert(contains(t_display_p4_runtime, + "const uint8_t event_count = std::min(count_reg & 0x0F, " + "kTca8418MaxKeyEvents);")); + assert(contains(t_display_p4_runtime, + "return dequeue_keyboard_event(out_key, out_pressed);")); + assert(contains(t_display_p4_runtime, + "bool dispatch_keyboard_event_to_focused_textarea(uint32_t key, bool pressed)")); + assert(contains(t_display_p4_runtime, + "lv_obj_send_event(textarea, LV_EVENT_KEY, &key_param)")); + assert(contains(t_display_p4_runtime, + "lv_obj_send_event(textarea, LV_EVENT_CANCEL, nullptr)")); + assert(contains(t_display_p4_runtime, "sync_keyboard_indev_group();")); + assert(contains(t_display_p4_runtime, "cycle_keyboard_backlight_from_key();")); + + const std::string lvgl_fs_utils = read_file( + repo_root / "modules/ui_shared/include/ui/support/lvgl_fs_utils.h"); + assert(contains(lvgl_fs_utils, "UI_FS_HAS_IDF_FLASH_PACK_STORAGE")); + assert(contains(lvgl_fs_utils, + "flash_storage_runtime::ensure_ready")); + assert(contains(lvgl_fs_utils, + "UI_FS_HAS_ARDUINO_FLASH_PACK_STORAGE || UI_FS_HAS_IDF_FLASH_PACK_STORAGE")); + + const std::string settings_components = read_file( + repo_root / "modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp"); + assert(contains(settings_components, "create_staged_list_content")); + assert(contains(settings_components, "visible_item_layout_matches_current")); + assert(contains(settings_components, + "settings kind=value visible_clean=0 staging_commit=0")); + assert(contains(settings_components, + "settings kind=structure visible_clean=0 staging_commit=1")); + assert(!contains(settings_components, "lv_obj_clean(g_state.list_panel);")); + + const std::string extensions_runtime = read_file( + repo_root / "modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp"); + assert(contains(extensions_runtime, "create_staged_body_panel")); + assert(contains(extensions_runtime, + "extensions install_complete package=%s full_app_rebuild=0")); + assert(!contains(extensions_runtime, "render_current_view();\n ui_request_rebuild_active_app();")); const std::string app_runtime = read_file( repo_root / "modules/ui_shared/src/ui/app_runtime.cpp"); @@ -383,6 +526,12 @@ int main(int argc, char** argv) "SYS I2C lock timeout requester=%s request_owner=%s at=%s:%d")); assert(contains(t_display_p4_board, "owner=%s owner_task=%s held_ms=%lu waiter=%s waiter_task=%s")); + assert(contains(t_display_p4_board, + "bool TDisplayP4Board::recoverExternal3v3ForKeyboardAttach")); + assert(contains(t_display_p4_board, + "Keyboard attach recovery cycling external 3.3V rail")); + assert(contains(t_display_p4_board, + "Keyboard attach recovery completed external 3.3V rail cycle")); const std::size_t managed_i2c_device = position_of( t_display_p4_board, "i2c_master_dev_handle_t TDisplayP4Board::getManagedSystemI2cDevice"); diff --git a/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h b/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h index bf21584f..43db9046 100644 --- a/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h +++ b/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h @@ -133,6 +133,8 @@ class TDisplayP4Board final : public BoardBase, public LoraBoard void keyboardSetBrightness(uint8_t level) override; uint8_t keyboardGetBrightness() override; bool ensureExternal3v3Power(); + bool recoverExternal3v3ForKeyboardAttach(uint32_t off_ms = 200, + uint32_t settle_ms = 300); bool configureBatteryGaugeCapacity(uint16_t design_capacity_mah, uint16_t full_charge_capacity_mah); void setKeyboardReady(bool ready); diff --git a/boards/t_display_p4/src/t_display_p4_board.cpp b/boards/t_display_p4/src/t_display_p4_board.cpp index 30689d27..a1b612ce 100644 --- a/boards/t_display_p4/src/t_display_p4_board.cpp +++ b/boards/t_display_p4/src/t_display_p4_board.cpp @@ -511,6 +511,91 @@ bool TDisplayP4Board::ensureExternal3v3Power() return true; } +bool TDisplayP4Board::recoverExternal3v3ForKeyboardAttach(uint32_t off_ms, + uint32_t settle_ms) +{ + if (!ensure_external_3v3_power_control()) + { + return false; + } + + if (!expander_ready_) + { + if ((!started_ && begin() != 0) || (!expander_ready_ && !initializeExpander())) + { + ESP_LOGE(kTag, "Failed to prepare XL9535 for keyboard 3.3V recovery"); + return false; + } + } + + const auto& io = ioExpanderPins(); + const auto& p = profile(); + if (!expanderPinMode(io.power_3v3, true)) + { + ESP_LOGE(kTag, "Failed to configure external 3.3V rail for keyboard recovery"); + return false; + } + + bool gps_wake_was_active = false; + bool gps_wake_state_known = false; + if (io.gps_wake >= 0) + { + gps_wake_state_known = + expanderReadActive(io.gps_wake, &gps_wake_was_active, p.gps_wake_active_high); + (void)expanderPinMode(io.gps_wake, true); + (void)expanderWriteActive(io.gps_wake, false, p.gps_wake_active_high); + } + + bool c6_was_active = false; + bool c6_state_known = false; + if (io.c6_enable >= 0) + { + c6_state_known = + expanderReadActive(io.c6_enable, &c6_was_active, p.c6_enable_active_high); + (void)expanderPinMode(io.c6_enable, true); + (void)expanderWriteActive(io.c6_enable, false, p.c6_enable_active_high); + } + + ESP_LOGW(kTag, + "Keyboard attach recovery cycling external 3.3V rail off_ms=%lu settle_ms=%lu gps_known=%u gps_was_active=%u c6_known=%u c6_was_active=%u", + static_cast(off_ms), + static_cast(settle_ms), + gps_wake_state_known ? 1U : 0U, + gps_wake_was_active ? 1U : 0U, + c6_state_known ? 1U : 0U, + c6_was_active ? 1U : 0U); + + if (!expanderWriteActive(io.power_3v3, false, p.power_3v3_active_high)) + { + ESP_LOGE(kTag, "Failed to deassert external 3.3V rail for keyboard recovery"); + return false; + } + vTaskDelay(pdMS_TO_TICKS(off_ms)); + + if (!expanderWriteActive(io.power_3v3, true, p.power_3v3_active_high)) + { + ESP_LOGE(kTag, "Failed to reassert external 3.3V rail for keyboard recovery"); + return false; + } + s_external_3v3_ready = true; + vTaskDelay(pdMS_TO_TICKS(settle_ms)); + + if (gps_wake_state_known) + { + (void)expanderWriteActive(io.gps_wake, gps_wake_was_active, p.gps_wake_active_high); + } + if (c6_state_known) + { + (void)expanderWriteActive(io.c6_enable, c6_was_active, p.c6_enable_active_high); + } + + ESP_LOGW(kTag, + "Keyboard attach recovery completed external 3.3V rail cycle gps_restored=%u c6_restored=%u", + gps_wake_state_known ? 1U : 0U, + c6_state_known ? 1U : 0U); + return true; +} + bool TDisplayP4Board::configureBatteryGaugeCapacity(uint16_t design_capacity_mah, uint16_t full_charge_capacity_mah) { diff --git a/boards/tdeck/src/tdeck_audio_runtime.cpp b/boards/tdeck/src/tdeck_audio_runtime.cpp index 7ad73eec..4064fe2b 100644 --- a/boards/tdeck/src/tdeck_audio_runtime.cpp +++ b/boards/tdeck/src/tdeck_audio_runtime.cpp @@ -3,6 +3,7 @@ #include "boards/tdeck/tdeck_audio_runtime.h" #include +#include #include #include @@ -50,6 +51,63 @@ constexpr std::size_t kCodec2DmaReservation = 0U; constexpr std::size_t kCodec2InternalFloor = 48U * 1024U; constexpr std::size_t kCodec2DmaFloor = 16U * 1024U; constexpr std::size_t kCodec2PsramFloor = 256U * 1024U; +constexpr std::size_t kVoiceLevelLogIntervalFrames = 25U; + +struct PcmLevel +{ + uint16_t peak = 0U; + uint16_t rms = 0U; +}; + +PcmLevel measurePcmLevel(const int16_t* samples, + std::size_t sample_count, + std::size_t stride = 1U) +{ + if (!samples || sample_count == 0U) + { + return {}; + } + + uint32_t peak = 0U; + uint64_t sum_of_squares = 0U; + for (std::size_t index = 0U; index < sample_count; ++index) + { + const int32_t sample = samples[index * stride]; + const uint32_t magnitude = + sample < 0 ? static_cast(-sample) : static_cast(sample); + peak = std::max(peak, magnitude); + sum_of_squares += static_cast(sample * sample); + } + + const float mean_square = + static_cast(sum_of_squares) / static_cast(sample_count); + return {static_cast(peak), + static_cast(std::sqrt(mean_square) + 0.5F)}; +} + +bool shouldLogVoiceLevel(std::size_t frame_index, std::size_t frame_count) +{ + const std::size_t completed_frames = frame_index + 1U; + return frame_index == 0U || (completed_frames % kVoiceLevelLogIntervalFrames) == 0U || + completed_frames == frame_count; +} + +void logVoicePcmLevel(std::size_t frame_index, + std::size_t frame_count, + const PcmLevel& decoded, + const PcmLevel& output, + uint8_t volume_percent) +{ + std::printf("[TDeck][Audio] voice level frame=%u/%u decoded_peak=%u decoded_rms=%u " + "output_peak=%u output_rms=%u volume=%u\n", + static_cast(frame_index + 1U), + static_cast(frame_count), + static_cast(decoded.peak), + static_cast(decoded.rms), + static_cast(output.peak), + static_cast(output.rms), + static_cast(volume_percent)); +} void secureClear(int16_t* samples, std::size_t sample_count) { @@ -249,12 +307,16 @@ bool TDeckAudioRuntime::playCodec2Voice(const uint8_t* encoded_media, { codec2_set_lpc_post_filter(decoder, 1, 0, 0.8F, 0.2F); complete = true; - for (std::size_t offset = 0U; offset < encoded_media_len; - offset += kCodec2BytesPerFrame) + const std::size_t frame_count = encoded_media_len / kCodec2BytesPerFrame; + for (std::size_t frame_index = 0U, offset = 0U; offset < encoded_media_len; + ++frame_index, offset += kCodec2BytesPerFrame) { codec2_decode(decoder, playback_pcm, const_cast(encoded_media + offset)); + const bool log_level = shouldLogVoiceLevel(frame_index, frame_count); + const PcmLevel decoded_level = + log_level ? measurePcmLevel(playback_pcm, kCodec2SamplesPerFrame) : PcmLevel{}; // Expand backwards so mono samples are never overwritten before // each sample reaches both output channels. for (std::size_t sample = kCodec2SamplesPerFrame; sample != 0U; @@ -269,6 +331,16 @@ bool TDeckAudioRuntime::playCodec2Voice(const uint8_t* encoded_media, static_cast(output_sample); } + if (log_level) + { + logVoicePcmLevel( + frame_index, + frame_count, + decoded_level, + measurePcmLevel(playback_pcm, kCodec2SamplesPerFrame, kCodec2OutputChannels), + output_volume); + } + size_t bytes_written = 0U; err = i2s_write(kSpeakerI2sPort, playback_pcm, diff --git a/builds/esp_idf/main/CMakeLists.txt b/builds/esp_idf/main/CMakeLists.txt index 59ebf24c..ed888d9b 100644 --- a/builds/esp_idf/main/CMakeLists.txt +++ b/builds/esp_idf/main/CMakeLists.txt @@ -1,5 +1,21 @@ include("${CMAKE_CURRENT_LIST_DIR}/../ESP_IDF_COMPONENT_SOURCES.cmake") +# GCC 14's O2 inlining cannot prove std::sort's bounded 8-entry range here. +# Keep the diagnostic, but do not make this specific false positive fatal on P4. +if(TRAIL_MATE_IDF_TARGET STREQUAL "t_display_p4_tft") + set_source_files_properties( + "${TRAILMATE_ROOT}/modules/core_chat/src/delivery/chat_message_ledger.cpp" + PROPERTIES COMPILE_OPTIONS "-Wno-error=array-bounds") + # copyHost() writes an explicit NUL after its bounded strncpy call. + set_source_files_properties( + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp" + PROPERTIES COMPILE_OPTIONS "-Wno-error=stringop-truncation") + # Every msgpack append checks `used` against the caller's 96-byte capacity. + set_source_files_properties( + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp" + PROPERTIES COMPILE_OPTIONS "-Wno-error=array-bounds") +endif() + set(TRAILMATE_ESP_IDF_PLATFORM_SOURCES ${TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES}) @@ -62,11 +78,14 @@ target_compile_definitions(${COMPONENT_LIB} PRIVATE if(TRAIL_MATE_IDF_TARGET STREQUAL "tab5") target_compile_definitions(${COMPONENT_LIB} PRIVATE TRAIL_MATE_ESP_BOARD_TAB5=1) elseif(TRAIL_MATE_IDF_TARGET STREQUAL "t_display_p4_tft") - target_compile_definitions(${COMPONENT_LIB} PRIVATE TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4=1) + target_compile_definitions(${COMPONENT_LIB} PRIVATE + TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4=1 + TRAIL_MATE_SD_IO_TRACE_LOG=1) elseif(TRAIL_MATE_IDF_TARGET STREQUAL "t_display_p4_amoled") target_compile_definitions(${COMPONENT_LIB} PRIVATE TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4=1 - TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4_AMOLED=1) + TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4_AMOLED=1 + TRAIL_MATE_SD_IO_TRACE_LOG=1) endif() target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_17) diff --git a/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults b/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults index bedb9aad..94b5e724 100644 --- a/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults +++ b/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults @@ -21,9 +21,13 @@ CONFIG_MBEDTLS_POLY1305_C=y CONFIG_MBEDTLS_CHACHAPOLY_C=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_PANEL_RM69A10=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_PIXEL_FORMAT_RGB565=y -CONFIG_LVGL_PORT_ENABLE_PPA=y +# P4 owns rotated DSI presentation in t_display_p4; do not also compile the +# generic esp_lvgl_port PPA rotation path. +# CONFIG_LVGL_PORT_ENABLE_PPA is not set CONFIG_LV_USE_TJPGD=y CONFIG_LV_USE_FS_POSIX=y CONFIG_LV_FS_POSIX_LETTER=70 CONFIG_LV_FS_POSIX_PATH="/fs" CONFIG_LV_FS_POSIX_CACHE_SIZE=0 +# Resource packs contain long archive, directory, and asset names. +CONFIG_FATFS_LFN_HEAP=y diff --git a/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults b/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults index 81de597f..a347399f 100644 --- a/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults +++ b/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults @@ -9,6 +9,9 @@ CONFIG_SPIRAM_MODE_HEX=y CONFIG_SPIRAM_SPEED_200M=y CONFIG_CACHE_L2_CACHE_256KB=y CONFIG_CACHE_L2_CACHE_LINE_128B=y +# Keep the DSI scan-out and storage path on an optimized P4 build. ESP-IDF +# documents this as a mitigation for MIPI DSI blue-screen underruns. +CONFIG_COMPILER_OPTIMIZATION_PERF=y CONFIG_ESPTOOLPY_FLASHFREQ_120M=y CONFIG_IDF_EXPERIMENTAL_FEATURES=y CONFIG_ESP_CONSOLE_SECONDARY_NONE=y @@ -30,9 +33,13 @@ CONFIG_MBEDTLS_POLY1305_C=y CONFIG_MBEDTLS_CHACHAPOLY_C=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_PANEL_HI8561=y CONFIG_TRAIL_MATE_T_DISPLAY_P4_PIXEL_FORMAT_RGB565=y -CONFIG_LVGL_PORT_ENABLE_PPA=y +# P4 owns rotated DSI presentation in t_display_p4; do not also compile the +# generic esp_lvgl_port PPA rotation path. +# CONFIG_LVGL_PORT_ENABLE_PPA is not set CONFIG_LV_USE_TJPGD=y CONFIG_LV_USE_FS_POSIX=y CONFIG_LV_FS_POSIX_LETTER=70 CONFIG_LV_FS_POSIX_PATH="/fs" CONFIG_LV_FS_POSIX_CACHE_SIZE=0 +# Resource packs contain long archive, directory, and asset names. +CONFIG_FATFS_LFN_HEAP=y diff --git a/builds/esp_idf/targets/tab5/sdkconfig.defaults b/builds/esp_idf/targets/tab5/sdkconfig.defaults index 21b393bd..ca74b1f0 100644 --- a/builds/esp_idf/targets/tab5/sdkconfig.defaults +++ b/builds/esp_idf/targets/tab5/sdkconfig.defaults @@ -25,3 +25,5 @@ CONFIG_LV_USE_FS_POSIX=y CONFIG_LV_FS_POSIX_LETTER=70 CONFIG_LV_FS_POSIX_PATH="/fs" CONFIG_LV_FS_POSIX_CACHE_SIZE=0 +# Resource packs contain long archive, directory, and asset names. +CONFIG_FATFS_LFN_HEAP=y diff --git a/builds/esp_idf/targets/tdeck/sdkconfig.defaults b/builds/esp_idf/targets/tdeck/sdkconfig.defaults index 3a09e5c8..2bcbc5ad 100644 --- a/builds/esp_idf/targets/tdeck/sdkconfig.defaults +++ b/builds/esp_idf/targets/tdeck/sdkconfig.defaults @@ -9,3 +9,5 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_TASK_WDT_TIMEOUT_S=20 CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y +# Resource packs contain long archive, directory, and asset names. +CONFIG_FATFS_LFN_HEAP=y diff --git a/builds/esp_idf/targets/tlora_pager/sdkconfig.defaults b/builds/esp_idf/targets/tlora_pager/sdkconfig.defaults index d293545a..353beddf 100644 --- a/builds/esp_idf/targets/tlora_pager/sdkconfig.defaults +++ b/builds/esp_idf/targets/tlora_pager/sdkconfig.defaults @@ -9,3 +9,5 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_TASK_WDT_TIMEOUT_S=20 CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y +# Resource packs contain long archive, directory, and asset names. +CONFIG_FATFS_LFN_HEAP=y diff --git a/builds/esp_idf/targets/twatch/sdkconfig.defaults b/builds/esp_idf/targets/twatch/sdkconfig.defaults index eafd04d3..c2ea8920 100644 --- a/builds/esp_idf/targets/twatch/sdkconfig.defaults +++ b/builds/esp_idf/targets/twatch/sdkconfig.defaults @@ -9,3 +9,5 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_TASK_WDT_TIMEOUT_S=20 CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y +# Resource packs contain long archive, directory, and asset names. +CONFIG_FATFS_LFN_HEAP=y diff --git a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp index b5ae415c..b4bf1cce 100644 --- a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp +++ b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp @@ -62,6 +62,12 @@ int main(int argc, char** argv) const std::string pager_audio_header = readFile( root / "platform/esp/arduino_common/include/platform/esp/arduino_common/" "voice/vmp_pager_audio.h"); + const std::string event_bus = readFile( + root / "platform/esp/arduino_common/include/sys/event_bus.h"); + const std::string event_runtime = readFile( + root / "platform/esp/arduino_common/src/app_event_runtime_support.cpp"); + const std::string chat_event_pump = readFile( + root / "modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp"); const std::string codec2 = readFile(root / "third_party/codec2/src/codec2.c"); const std::string codec2_internal = readFile(root / "third_party/codec2/src/codec2_internal.h"); @@ -306,6 +312,24 @@ int main(int argc, char** argv) assert(session.find("[VMP][RX] shard quorum reached unique_shards=") != std::string::npos); assert(session.find("[VMP][RX] inbox durable_commit") != std::string::npos); + // A received clip becomes visible only after its inbox transaction has + // committed. Its event transports the local ID, not a copied PCM/Codec2 + // frame, and is queued non-blocking after the receive mutex is released. + assert(event_bus.find("ChatVoiceMessageEvent") != std::string::npos); + assert(event_bus.find("ChatMessageContentKind") != std::string::npos); + assert(event_bus.find("uint64_t local_id") != std::string::npos); + assert(session.find("publishIncomingVoiceMessage(completed_conversation_channel,") != + std::string::npos); + assert(session.find("const uint8_t completed_conversation_channel = incoming_control_.conversation_channel") != + std::string::npos); + assert(session.find("sys::EventBus::publish(event, 0U)") != + std::string::npos); + assert(event_runtime.find("ChatMessageContentKind::Voice") != + std::string::npos); + assert(event_runtime.find("show_notice(notice, 3000)") != + std::string::npos); + assert(chat_event_pump.find("ChatMessageContentKind::Voice") != + std::string::npos); // Codec2 and the Pager I2S/codec/I2C call chain execute on the short-lived // VMP worker, not the UI task. ESP-IDF interprets xTaskCreatePinnedToCore @@ -363,6 +387,33 @@ int main(int argc, char** argv) std::string::npos); assert(pager_audio.find("[VMP][AUDIO] level playback") != std::string::npos); + // VMP keeps its calibrated gain policy local to the voice adapter. Do not + // alter the shared codec volume curve, and do not add a second PCM buffer + // merely to meter it: the gain/clip telemetry operates in FrameScratch. + assert(pager_audio.find("kCaptureGainDb = 30.0F") != std::string::npos); + assert(pager_audio.find("kPlaybackPcmGainQ8 = 512U") != std::string::npos); + assert(pager_audio.find("kCapturePcmGainQ8") == std::string::npos); + assert(pager_audio.find("applyBoundedPcmGain(frame_scratch_->mono") != + std::string::npos); + assert(pager_audio.find("[VMP][AUDIO] capture summary") != + std::string::npos); + assert(pager_audio.find("[VMP][AUDIO] playback summary") != + std::string::npos); + assert(pager_audio.find("source=left_mono") != std::string::npos); + assert(pager_audio.find("input_raw_clip=") != std::string::npos); + const std::size_t mix_capture = + positionOf(pager_audio, "void PagerCodec2Audio::mixCaptureToMono()"); + const std::size_t duplicate_playback = + positionOfAfter(pager_audio, "void PagerCodec2Audio::duplicatePlaybackToStereo()", + mix_capture); + const std::string mix_capture_body = + pager_audio.substr(mix_capture, duplicate_playback - mix_capture); + assert(mix_capture_body.find("level.input = measurePcmLevel") != + std::string::npos); + assert(mix_capture_body.find("frame_scratch_->stereo[index * kHardwareChannels]") != + std::string::npos); + assert(mix_capture_body.find("use_left") == std::string::npos); + assert(mix_capture_body.find("use_right") == std::string::npos); assert(pager_audio.find("struct PagerCodec2Audio::FrameScratch\n{\n int16_t stereo[") != std::string::npos); assert(pager_audio.find("PcmLevel stereo[") == std::string::npos); diff --git a/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp b/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp index 71e7c7e3..4b494e4c 100644 --- a/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp +++ b/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp @@ -90,7 +90,17 @@ void ChatPageRuntimeEventPump::handleChatNewMessage( { if (ui_ != nullptr) { - ui_->onRuntimeMessageArrived(event.msg_id); + if (event.content_kind == sys::ChatMessageContentKind::Voice) + { + // Voice lives in the VMP attachment inbox rather than the text + // ledger. Refresh the same unread projection used for incoming + // text; its fixed metadata buffer supplies the voice bubble. + ui_->onRuntimeUnreadChanged(); + } + else + { + ui_->onRuntimeMessageArrived(event.msg_id); + } } } diff --git a/modules/ui_lvgl_ux_packs/src/packs/t_display_p4_touch_ux_pack.cpp b/modules/ui_lvgl_ux_packs/src/packs/t_display_p4_touch_ux_pack.cpp index bc85dae2..f260991c 100644 --- a/modules/ui_lvgl_ux_packs/src/packs/t_display_p4_touch_ux_pack.cpp +++ b/modules/ui_lvgl_ux_packs/src/packs/t_display_p4_touch_ux_pack.cpp @@ -16,7 +16,7 @@ void build_page_manifest(ScreenRegistry& out) (void)out.add({ScreenId::Gps, "GPS", true}); (void)out.add({ScreenId::Team, "Team", true}); (void)out.add({ScreenId::Tracker, "Tracker", true}); - (void)out.add({ScreenId::EnergySweep, "Packet Probe", true}); + (void)out.add({ScreenId::EnergySweep, "Protocol Probe", true}); (void)out.add({ScreenId::WalkieTalkie, "Walkie", true}); (void)out.add({ScreenId::Sstv, "SSTV", true}); (void)out.add({ScreenId::Extensions, "Extensions", true}); diff --git a/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp b/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp index 492e85f9..0960bd4a 100644 --- a/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp +++ b/modules/ui_lvgl_ux_packs/src/packs/uconsole_desktop_ux_pack.cpp @@ -51,7 +51,7 @@ void UConsoleDesktopUxPack::buildScreens(ScreenRegistry& out) const (void)out.add({ScreenId::Gps, "GPS", true}); (void)out.add({ScreenId::Team, "Team", true}); (void)out.add({ScreenId::Tracker, "Tracker", true}); - (void)out.add({ScreenId::EnergySweep, "Packet Probe", true}); + (void)out.add({ScreenId::EnergySweep, "Protocol Probe", true}); (void)out.add({ScreenId::WalkieTalkie, "Walkie", true}); (void)out.add({ScreenId::Sstv, "SSTV", true}); (void)out.add({ScreenId::Extensions, "Extensions", true}); 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 7ba840c5..88bb688e 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -357,6 +357,11 @@ struct UiState lv_obj_t* content = nullptr; lv_obj_t* filter_panel = nullptr; lv_obj_t* list_panel = nullptr; + // The scrolling panel remains stable. Rebuilt list content is constructed + // in a hidden sibling and swapped only after it is complete, so settings + // updates never clear the user-visible container. + lv_obj_t* list_content = nullptr; + lv_obj_t* visible_list_content = nullptr; lv_obj_t* list_back_btn = nullptr; ::ui::widgets::TopBar top_bar; lv_obj_t* filter_buttons[8]{}; diff --git a/modules/ui_shared/include/ui/support/lvgl_fs_utils.h b/modules/ui_shared/include/ui/support/lvgl_fs_utils.h index aff01f7f..a7885fab 100644 --- a/modules/ui_shared/include/ui/support/lvgl_fs_utils.h +++ b/modules/ui_shared/include/ui/support/lvgl_fs_utils.h @@ -4,10 +4,23 @@ #include -#if (defined(ARDUINO_ARCH_ESP32) || defined(ESP_PLATFORM) || defined(ARDUINO)) && __has_include() +#if (defined(ARDUINO_ARCH_ESP32) || defined(ARDUINO)) && __has_include() #include #include #include +#define UI_FS_HAS_ARDUINO_FLASH_PACK_STORAGE 1 +#else +#define UI_FS_HAS_ARDUINO_FLASH_PACK_STORAGE 0 +#endif + +#if defined(ESP_PLATFORM) && defined(LV_USE_FS_POSIX) && LV_USE_FS_POSIX +#include "platform/esp/idf_common/flash_storage_runtime.h" +#define UI_FS_HAS_IDF_FLASH_PACK_STORAGE 1 +#else +#define UI_FS_HAS_IDF_FLASH_PACK_STORAGE 0 +#endif + +#if UI_FS_HAS_ARDUINO_FLASH_PACK_STORAGE || UI_FS_HAS_IDF_FLASH_PACK_STORAGE #define UI_FS_HAS_FLASH_PACK_STORAGE 1 #else #define UI_FS_HAS_FLASH_PACK_STORAGE 0 @@ -18,7 +31,7 @@ namespace ui::fs inline bool ensure_flash_storage_ready(bool allow_format_on_fail = false) { -#if UI_FS_HAS_FLASH_PACK_STORAGE +#if UI_FS_HAS_ARDUINO_FLASH_PACK_STORAGE static bool mounted = false; static bool mount_attempted = false; static bool format_mount_attempted = false; @@ -56,6 +69,11 @@ inline bool ensure_flash_storage_ready(bool allow_format_on_fail = false) } return mounted; +#elif UI_FS_HAS_IDF_FLASH_PACK_STORAGE + // ESP-IDF mounts the same FAT partition through esp_vfs_fat rather than + // Arduino's FFat object. The LVGL POSIX driver exposes that mount as F:. + return ::platform::esp::idf_common::flash_storage_runtime::ensure_ready( + allow_format_on_fail); #else (void)allow_format_on_fail; return false; diff --git a/modules/ui_shared/src/ui/app_runtime.cpp b/modules/ui_shared/src/ui/app_runtime.cpp index a8171150..de37c456 100644 --- a/modules/ui_shared/src/ui/app_runtime.cpp +++ b/modules/ui_shared/src/ui/app_runtime.cpp @@ -332,12 +332,26 @@ void rebuild_active_app_timer_cb(lv_timer_t* timer) return; } +#if defined(ESP_PLATFORM) + ESP_LOGI(kTag, + "[UI][Txn] active_rebuild begin app=%s parent=%p children_before=%lu", + app->name(), + parent, + static_cast(child_count(parent))); +#endif app->exit(parent); if (parent != nullptr && child_count(parent) > 0) { lv_obj_clean(parent); } app->enter(parent); +#if defined(ESP_PLATFORM) + ESP_LOGI(kTag, + "[UI][Txn] active_rebuild complete app=%s parent=%p children_after=%lu", + app->name(), + parent, + static_cast(child_count(parent))); +#endif } } // namespace diff --git a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp index 23631eff..4ff18d72 100644 --- a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp +++ b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp @@ -4086,11 +4086,13 @@ void set_label_text(lv_obj_t* label, const char* english) const char* localized = tr(english); if (label) { +#if defined(ARDUINO_T_DECK_PRO) const char* current = lv_label_get_text(label); if (current != nullptr && std::strcmp(current, localized) == 0) { return; } +#endif lv_label_set_text(label, localized); ::ui::fonts::apply_localized_font(label, localized, lv_obj_get_style_text_font(label, LV_PART_MAIN)); } @@ -4101,11 +4103,13 @@ void set_label_text_raw(lv_obj_t* label, const char* text) if (label) { const char* value = text ? text : ""; +#if defined(ARDUINO_T_DECK_PRO) const char* current = lv_label_get_text(label); if (current != nullptr && std::strcmp(current, value) == 0) { return; } +#endif #if UI_I18N_ROUTE_LOG_ENABLE if (route_text_is_interesting(value)) { @@ -4134,11 +4138,13 @@ void set_content_label_text(lv_obj_t* label, const char* english) const char* localized = tr(english); if (label) { +#if defined(ARDUINO_T_DECK_PRO) const char* current = lv_label_get_text(label); if (current != nullptr && std::strcmp(current, localized) == 0) { return; } +#endif lv_label_set_text(label, localized); ::ui::fonts::apply_content_font(label, localized, lv_obj_get_style_text_font(label, LV_PART_MAIN)); } @@ -4149,11 +4155,13 @@ void set_content_label_text_raw(lv_obj_t* label, const char* text) if (label) { const char* value = text ? text : ""; +#if defined(ARDUINO_T_DECK_PRO) const char* current = lv_label_get_text(label); if (current != nullptr && std::strcmp(current, value) == 0) { return; } +#endif #if UI_I18N_ROUTE_LOG_ENABLE if (route_text_is_interesting(value)) { diff --git a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp index 7229857d..9e0f3a6a 100644 --- a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp @@ -21,6 +21,10 @@ #include "ui/widgets/foreground_operation_overlay.h" #include "ui/widgets/top_bar.h" +#if defined(ESP_PLATFORM) +#include "esp_log.h" +#endif + #if !defined(LV_FONT_MONTSERRAT_16) || !LV_FONT_MONTSERRAT_16 #define lv_font_montserrat_16 lv_font_montserrat_14 #endif @@ -39,6 +43,10 @@ namespace packs = ::ui::runtime::packs; constexpr std::size_t kInvalidIndex = static_cast(-1); +#if defined(ESP_PLATFORM) +constexpr char kLogTag[] = "extensions"; +#endif + enum class MainView : uint8_t { List = 0, @@ -64,7 +72,9 @@ struct RuntimeState lv_obj_t* header_card = nullptr; lv_obj_t* title_label = nullptr; lv_obj_t* status_label = nullptr; + lv_obj_t* body_host = nullptr; lv_obj_t* body_panel = nullptr; + lv_obj_t* visible_body_panel = nullptr; lv_obj_t* detail_back_btn = nullptr; lv_obj_t* primary_action_btn = nullptr; lv_obj_t* uninstall_btn = nullptr; @@ -429,6 +439,53 @@ lv_obj_t* create_section_card(lv_obj_t* parent, return card; } +lv_obj_t* create_staged_body_panel(lv_obj_t* parent) +{ + if (parent == nullptr) + { + return nullptr; + } + + const auto& profile = ::ui::page_profile::current(); + lv_obj_t* panel = lv_obj_create(parent); + if (panel == nullptr) + { + return nullptr; + } + lv_obj_set_width(panel, LV_PCT(100)); + lv_obj_set_height(panel, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(panel, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(panel, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(panel, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(panel, profile.list_panel_pad_row, LV_PART_MAIN); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + return panel; +} + +void commit_body_transaction() +{ + if (s_runtime.body_panel == nullptr || + s_runtime.body_panel == s_runtime.visible_body_panel) + { + return; + } + + lv_obj_clear_flag(s_runtime.body_panel, LV_OBJ_FLAG_HIDDEN); + if (s_runtime.visible_body_panel != nullptr) + { + lv_obj_del(s_runtime.visible_body_panel); + } + s_runtime.visible_body_panel = s_runtime.body_panel; +#if defined(ESP_PLATFORM) + ESP_LOGI(kLogTag, "[UI][Txn] extensions kind=structure visible_clean=0 staging_commit=1"); +#endif +} + void clear_body() { s_runtime.detail_back_btn = nullptr; @@ -437,14 +494,30 @@ void clear_body() s_runtime.connect_btn = nullptr; s_runtime.list_buttons.clear(); - if (s_runtime.body_panel != nullptr) + if (s_runtime.body_panel != nullptr && + s_runtime.body_panel != s_runtime.visible_body_panel) { - lv_obj_clean(s_runtime.body_panel); + // A nested render replaces only the not-yet-visible staging tree. + // Keep the previous complete view alive until its successor is ready. + lv_obj_del(s_runtime.body_panel); + s_runtime.body_panel = s_runtime.visible_body_panel; } + + lv_obj_t* staged_panel = create_staged_body_panel(s_runtime.body_host); + if (staged_panel == nullptr) + { +#if defined(ESP_PLATFORM) + ESP_LOGE(kLogTag, "[UI][Txn] extensions kind=structure staging_begin_failed"); +#endif + return; + } + lv_obj_add_flag(staged_panel, LV_OBJ_FLAG_HIDDEN); + s_runtime.body_panel = staged_panel; } void sync_focus_group(lv_obj_t* preferred_focus = nullptr) { + commit_body_transaction(); if (app_g == nullptr) { return; @@ -690,7 +763,11 @@ void sync_install_ui(bool notify_completion) s_runtime.filter = PackageFilter::Installed; s_runtime.view = MainView::Detail; render_current_view(); - ui_request_rebuild_active_app(); +#if defined(ESP_PLATFORM) + ESP_LOGI(kLogTag, + "[UI][Txn] extensions install_complete package=%s full_app_rebuild=0", + status.package_id.empty() ? "" : status.package_id.c_str()); +#endif } else if (status.phase == packs::PackageInstallPhase::Failed) { @@ -1281,16 +1358,17 @@ void create_main_panel(lv_obj_t* parent) two_pane_styles::apply_label_muted(s_runtime.status_label); ::ui::i18n::set_label_text(s_runtime.status_label, "Loading..."); - s_runtime.body_panel = lv_obj_create(s_runtime.main_panel); - lv_obj_set_width(s_runtime.body_panel, LV_PCT(100)); - lv_obj_set_height(s_runtime.body_panel, 0); - lv_obj_set_flex_grow(s_runtime.body_panel, 1); - lv_obj_set_style_bg_opa(s_runtime.body_panel, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(s_runtime.body_panel, 0, 0); - lv_obj_set_style_pad_all(s_runtime.body_panel, 0, 0); - lv_obj_set_style_pad_row(s_runtime.body_panel, profile.list_panel_pad_row, 0); - lv_obj_set_flex_flow(s_runtime.body_panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_runtime.body_panel, LV_SCROLLBAR_MODE_AUTO); + s_runtime.body_host = lv_obj_create(s_runtime.main_panel); + lv_obj_set_width(s_runtime.body_host, LV_PCT(100)); + lv_obj_set_height(s_runtime.body_host, 0); + lv_obj_set_flex_grow(s_runtime.body_host, 1); + lv_obj_set_style_bg_opa(s_runtime.body_host, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_runtime.body_host, 0, 0); + lv_obj_set_style_pad_all(s_runtime.body_host, 0, 0); + lv_obj_set_scrollbar_mode(s_runtime.body_host, LV_SCROLLBAR_MODE_AUTO); + + s_runtime.body_panel = create_staged_body_panel(s_runtime.body_host); + s_runtime.visible_body_panel = s_runtime.body_panel; } } // namespace 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 feb5581e..233b667f 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 @@ -5539,6 +5539,108 @@ static void list_item_focused_cb(lv_event_t* e) } } +static lv_obj_t* create_staged_list_content(lv_obj_t* parent) +{ + if (parent == nullptr) + { + return nullptr; + } + + lv_obj_t* content = lv_obj_create(parent); + if (content == nullptr) + { + return nullptr; + } + lv_obj_set_width(content, LV_PCT(100)); + lv_obj_set_height(content, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(content, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(content, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(content, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(content, + ::ui::page_profile::current().list_panel_pad_row, + LV_PART_MAIN); + lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); + return content; +} + +static bool visible_item_layout_matches_current() +{ + if (g_state.visible_list_content == nullptr || + g_state.current_category < 0 || + g_state.current_category >= static_cast(sizeof(kCategories) / sizeof(kCategories[0]))) + { + return false; + } + + const CategoryDef& category = kCategories[g_state.current_category]; + std::size_t expected_index = 0; + for (std::size_t index = 0; index < category.item_count; ++index) + { + if (!should_show_item(category.items[index])) + { + continue; + } + if (expected_index >= g_state.item_count || + g_state.item_widgets[expected_index].def != &category.items[index]) + { + return false; + } + ++expected_index; + } + return expected_index == g_state.item_count; +} + +static bool begin_list_transaction() +{ + if (g_state.list_panel == nullptr) + { + return false; + } + + if (g_state.list_content != nullptr && + g_state.list_content != g_state.visible_list_content) + { + // A nested refresh supersedes a not-yet-visible staging tree. The + // previous visible tree remains untouched until a complete successor + // has been built. + lv_obj_del(g_state.list_content); + } + + g_state.list_content = create_staged_list_content(g_state.list_panel); + if (g_state.list_content == nullptr) + { + return false; + } + lv_obj_add_flag(g_state.list_content, LV_OBJ_FLAG_HIDDEN); + return true; +} + +static void commit_list_transaction() +{ + if (g_state.list_content == nullptr || + g_state.list_content == g_state.visible_list_content) + { + return; + } + + lv_obj_clear_flag(g_state.list_content, LV_OBJ_FLAG_HIDDEN); + if (g_state.visible_list_content != nullptr) + { + lv_obj_del(g_state.visible_list_content); + } + g_state.visible_list_content = g_state.list_content; +#if defined(ESP_PLATFORM) + ESP_LOGI(kLogTag, + "[UI][Txn] settings kind=structure visible_clean=0 staging_commit=1 items=%u", + static_cast(g_state.item_count)); +#endif +} + static void build_item_list() { if (!g_state.list_panel) return; @@ -5546,6 +5648,25 @@ static void build_item_list() { return; } + + if (visible_item_layout_matches_current()) + { + refresh_visible_item_values(); +#if defined(ESP_PLATFORM) + ESP_LOGI(kLogTag, + "[UI][Txn] settings kind=value visible_clean=0 staging_commit=0 items=%u", + static_cast(g_state.item_count)); +#endif + return; + } + + if (!begin_list_transaction()) + { +#if defined(ESP_PLATFORM) + ESP_LOGE(kLogTag, "[UI][Txn] settings kind=structure staging_begin_failed"); +#endif + return; + } s_building_list = true; #if defined(ESP_PLATFORM) ESP_LOGI(kLogTag, @@ -5553,7 +5674,6 @@ static void build_item_list() g_state.current_category); #endif g_state.list_back_btn = nullptr; - lv_obj_clean(g_state.list_panel); g_state.item_count = 0; lv_obj_clear_flag(g_state.list_panel, LV_OBJ_FLAG_SCROLLABLE); @@ -5615,7 +5735,7 @@ static void build_item_list() widget.def->pref_key ? widget.def->pref_key : "", static_cast(widget.def->type)); #endif - lv_obj_t* btn = lv_btn_create(g_state.list_panel); + lv_obj_t* btn = lv_btn_create(g_state.list_content); configure_list_item_button(btn); style::apply_list_item(btn); @@ -5631,7 +5751,7 @@ static void build_item_list() } if (should_show_settings_list_back_button()) { - g_state.list_back_btn = lv_btn_create(g_state.list_panel); + g_state.list_back_btn = lv_btn_create(g_state.list_content); lv_obj_set_size(g_state.list_back_btn, LV_PCT(100), ::ui::page_profile::resolve_control_button_height()); lv_obj_set_style_pad_left(g_state.list_back_btn, 10, LV_PART_MAIN); lv_obj_set_style_pad_right(g_state.list_back_btn, 10, LV_PART_MAIN); @@ -5645,6 +5765,7 @@ static void build_item_list() lv_obj_add_event_cb(g_state.list_back_btn, on_list_back_clicked, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(g_state.list_back_btn, list_item_focused_cb, LV_EVENT_FOCUSED, nullptr); } + commit_list_transaction(); settings::ui::input::on_ui_refreshed(); lv_obj_scroll_to_y(g_state.list_panel, 0, LV_ANIM_OFF); lv_obj_invalidate(g_state.list_panel); diff --git a/modules/ui_shared/src/ui/ui_status.cpp b/modules/ui_shared/src/ui/ui_status.cpp index f0fcd904..ade86f57 100644 --- a/modules/ui_shared/src/ui/ui_status.cpp +++ b/modules/ui_shared/src/ui/ui_status.cpp @@ -187,10 +187,15 @@ void apply_icon(lv_obj_t* icon, const lv_image_dsc_t* src, bool visible) { return; } +#if defined(ARDUINO_T_DECK_PRO) if (src && lv_image_get_src(icon) != src) +#else + if (src) +#endif { lv_image_set_src(icon, src); } +#if defined(ARDUINO_T_DECK_PRO) const bool hidden = lv_obj_has_flag(icon, LV_OBJ_FLAG_HIDDEN); if (visible && hidden) { @@ -200,6 +205,16 @@ void apply_icon(lv_obj_t* icon, const lv_image_dsc_t* src, bool visible) { lv_obj_add_flag(icon, LV_OBJ_FLAG_HIDDEN); } +#else + if (visible) + { + lv_obj_clear_flag(icon, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(icon, LV_OBJ_FLAG_HIDDEN); + } +#endif } void apply_menu_icons(const StatusSnapshot& snap) @@ -235,6 +250,7 @@ void apply_menu_icons(const StatusSnapshot& snap) snap.ble_enabled || #endif snap.radio_mod_visible || snap.walkie_monitor || (snap.unread > 0); +#if defined(ARDUINO_T_DECK_PRO) const bool row_hidden = lv_obj_has_flag(s_menu_status_row, LV_OBJ_FLAG_HIDDEN); if (any && row_hidden) { @@ -244,6 +260,16 @@ void apply_menu_icons(const StatusSnapshot& snap) { lv_obj_add_flag(s_menu_status_row, LV_OBJ_FLAG_HIDDEN); } +#else + if (any) + { + lv_obj_clear_flag(s_menu_status_row, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(s_menu_status_row, LV_OBJ_FLAG_HIDDEN); + } +#endif } void apply_menu_badge(const StatusSnapshot& snap) @@ -254,10 +280,14 @@ void apply_menu_badge(const StatusSnapshot& snap) } if (snap.unread <= 0) { +#if defined(ARDUINO_T_DECK_PRO) if (!lv_obj_has_flag(s_chat_badge, LV_OBJ_FLAG_HIDDEN)) { lv_obj_add_flag(s_chat_badge, LV_OBJ_FLAG_HIDDEN); } +#else + lv_obj_add_flag(s_chat_badge, LV_OBJ_FLAG_HIDDEN); +#endif return; } @@ -270,6 +300,7 @@ void apply_menu_badge(const StatusSnapshot& snap) { snprintf(buf, sizeof(buf), "%d", snap.unread); } +#if defined(ARDUINO_T_DECK_PRO) const char* current = lv_label_get_text(s_chat_badge_label); if (current == nullptr || std::strcmp(current, buf) != 0) { @@ -279,6 +310,10 @@ void apply_menu_badge(const StatusSnapshot& snap) { lv_obj_clear_flag(s_chat_badge, LV_OBJ_FLAG_HIDDEN); } +#else + lv_label_set_text(s_chat_badge_label, buf); + lv_obj_clear_flag(s_chat_badge, LV_OBJ_FLAG_HIDDEN); +#endif } void status_timer_cb(lv_timer_t* /*timer*/) diff --git a/modules/ui_shared/src/ui/widgets/busy_overlay.cpp b/modules/ui_shared/src/ui/widgets/busy_overlay.cpp index ec14797a..70dc3eb5 100644 --- a/modules/ui_shared/src/ui/widgets/busy_overlay.cpp +++ b/modules/ui_shared/src/ui/widgets/busy_overlay.cpp @@ -263,8 +263,6 @@ void update(const char* title, const char* detail) } update_content(title, detail); - lv_obj_move_foreground(s_root); - request_refresh(); } void set_progress(int progress_percent) @@ -275,7 +273,6 @@ void set_progress(int progress_percent) } apply_progress(progress_percent); - request_refresh(); } void hide() diff --git a/platform/esp/arduino_common/include/sys/event_bus.h b/platform/esp/arduino_common/include/sys/event_bus.h index af440045..496ef122 100644 --- a/platform/esp/arduino_common/include/sys/event_bus.h +++ b/platform/esp/arduino_common/include/sys/event_bus.h @@ -83,19 +83,33 @@ struct Event virtual ~Event() = default; }; +enum class ChatMessageContentKind : uint8_t +{ + Text = 0U, + Voice, +}; + /** - * @brief Chat new message event + * @brief One incoming chat item, either text or a local attachment. + * + * The content kind occupies the padding that already followed `channel`, so + * normal text notifications retain their event footprint. Attachment media + * is deliberately not copied into this transport event. */ struct ChatNewMessageEvent : public Event { uint8_t channel; + ChatMessageContentKind content_kind; uint32_t msg_id; char text[64]; // Message text (truncated if needed) chat::RxMeta rx_meta; ChatNewMessageEvent(uint8_t ch, uint32_t id, const char* msg_text = "", const chat::RxMeta* meta = nullptr) - : Event(EventType::ChatNewMessage), channel(ch), msg_id(id) + : Event(EventType::ChatNewMessage), + channel(ch), + content_kind(ChatMessageContentKind::Text), + msg_id(id) { if (msg_text) { @@ -113,6 +127,25 @@ struct ChatNewMessageEvent : public Event } }; +/** + * @brief Incoming voice variant of the standard new-chat-item event. + * + * The VMP inbox retains the media and metadata; this short-lived event carries + * only the attachment identifier used for diagnostics and future routing. + * It deliberately shares EventType::ChatNewMessage so notifications and chat + * refresh are a single incoming-message flow rather than a voice side path. + */ +struct ChatVoiceMessageEvent final : public ChatNewMessageEvent +{ + uint64_t local_id; + + ChatVoiceMessageEvent(uint8_t channel, uint64_t id) + : ChatNewMessageEvent(channel, 0U, "Voice message"), local_id(id) + { + content_kind = ChatMessageContentKind::Voice; + } +}; + /** * @brief Chat send result event */ diff --git a/platform/esp/arduino_common/include/ui/LV_Helper.h b/platform/esp/arduino_common/include/ui/LV_Helper.h index c8d3f68b..185bbc7d 100644 --- a/platform/esp/arduino_common/include/ui/LV_Helper.h +++ b/platform/esp/arduino_common/include/ui/LV_Helper.h @@ -1,6 +1,8 @@ #pragma once +#if defined(ARDUINO_T_DECK_PRO) #include +#endif #include @@ -12,7 +14,9 @@ class LilyGo_Display; #endif void beginLvglHelper(LilyGo_Display& display, bool debug = false); +#if defined(ARDUINO_T_DECK_PRO) void serviceLvglDisplay(uint32_t now_ms); +#endif // Marks the next physical frame as a full refresh when the active display // supports one. Callers use this only for visual lifecycle boundaries. void requestLvglFullRefresh(); diff --git a/platform/esp/arduino_common/src/LV_Helper_v9.cpp b/platform/esp/arduino_common/src/LV_Helper_v9.cpp index dfa2635a..39fd6044 100644 --- a/platform/esp/arduino_common/src/LV_Helper_v9.cpp +++ b/platform/esp/arduino_common/src/LV_Helper_v9.cpp @@ -1417,6 +1417,7 @@ void beginLvglHelper(LilyGo_Display& board, bool debug) lv_set_default_group(default_group); } +#if defined(ARDUINO_T_DECK_PRO) void serviceLvglDisplay(uint32_t now_ms) { if (disp_drv == nullptr) @@ -1430,6 +1431,7 @@ void serviceLvglDisplay(uint32_t now_ms) plane->serviceDisplay(now_ms); } } +#endif void requestLvglFullRefresh() { 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 e6ea28f8..0ac3bd81 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -218,8 +218,14 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) case sys::EventType::ChatNewMessage: { auto* msg_event = static_cast(event); + const bool voice_message = + msg_event->content_kind == sys::ChatMessageContentKind::Voice; + const char* const notice = voice_message + ? ::ui::i18n::tr("Voice message") + : msg_event->text; const bool alerts_enabled = messageAlertsEnabled(); - std::printf("[UI][event] chat_new_message msg=%lu ch=%u len=%u origin=%u alerts=%u text='%s'\n", + std::printf("[UI][event] chat_new_message kind=%s msg=%lu ch=%u len=%u origin=%u alerts=%u text='%s'\n", + voice_message ? "voice" : "text", static_cast(msg_event->msg_id), static_cast(msg_event->channel), static_cast(std::strlen(msg_event->text)), @@ -229,13 +235,15 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) if (alerts_enabled) { triggerMessageFeedback(app_context); - ::ui::feedback::show_notice(msg_event->text, 3000); - std::printf("[UI][notice] chat_new_message msg=%lu shown=1\n", + ::ui::feedback::show_notice(notice, 3000); + std::printf("[UI][notice] chat_new_message kind=%s msg=%lu shown=1\n", + voice_message ? "voice" : "text", static_cast(msg_event->msg_id)); } else { - std::printf("[UI][notice] chat_new_message msg=%lu shown=0 reason=alerts_disabled\n", + std::printf("[UI][notice] chat_new_message kind=%s msg=%lu shown=0 reason=alerts_disabled\n", + voice_message ? "voice" : "text", static_cast(msg_event->msg_id)); } break; @@ -315,7 +323,8 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) if (event->type == sys::EventType::ChatNewMessage) { auto* msg_event = static_cast(event); - std::printf("[UI][chat_runtime] dispatch chat_new_message msg=%lu ch=%u\n", + std::printf("[UI][chat_runtime] dispatch chat_new_message kind=%u msg=%lu ch=%u\n", + static_cast(msg_event->content_kind), static_cast(msg_event->msg_id), static_cast(msg_event->channel)); } diff --git a/platform/esp/arduino_common/src/display_runtime.cpp b/platform/esp/arduino_common/src/display_runtime.cpp index 03f309b7..14660132 100644 --- a/platform/esp/arduino_common/src/display_runtime.cpp +++ b/platform/esp/arduino_common/src/display_runtime.cpp @@ -4,7 +4,9 @@ #include "lvgl.h" #include "platform/esp/boards/board_runtime.h" +#if defined(ARDUINO_T_DECK_PRO) #include "ui/LV_Helper.h" +#endif #include "ui/localization.h" #ifndef MAIN_TIMING_DEBUG @@ -51,10 +53,12 @@ void tickIfDue(uint32_t now_ms) ::ui::i18n::on_lvgl_frame_completed(); } +#if defined(ARDUINO_T_DECK_PRO) // This runs after LVGL has copied all invalidated pixels for the current - // frame. Boards with a slow physical display can merge those regions and - // commit one hardware refresh without retaining an LVGL-owned buffer. + // frame. The EPD can merge those regions and commit one hardware refresh + // without retaining an LVGL-owned buffer. serviceLvglDisplay(now_ms); +#endif #if MAIN_TIMING_DEBUG if (run_lvgl) diff --git a/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp b/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp index c4836d6d..4c8f8612 100644 --- a/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp +++ b/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp @@ -193,6 +193,13 @@ void on_event(const sys::Event& event) case sys::EventType::ChatNewMessage: { const auto& msg_evt = static_cast(event); + // HostLink's EvRxMsg has a text-ledger payload only. Do not serialize + // a voice inbox identifier as a synthetic text message; the UI still + // receives the shared ChatNewMessage event locally. + if (msg_evt.content_kind != sys::ChatMessageContentKind::Text) + { + break; + } const chat::ChatMessage* msg = app::messagingFacade().getChatService().getMessage(msg_evt.msg_id); diff --git a/platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp b/platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp index 1c8d82be..0a9ffbbf 100644 --- a/platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp +++ b/platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp @@ -1415,25 +1415,72 @@ bool ensure_dir_recursive(const std::string& logical_dir) } std::string host_dir = host_path(logical_dir); - std::string current; - for (char ch : host_dir) + const std::string mount_point = platform::esp::idf_common::flash_storage_runtime::mount_point(); + if (host_dir.empty() || mount_point.empty() || !starts_with(host_dir, mount_point.c_str()) || + (host_dir.size() > mount_point.size() && host_dir[mount_point.size()] != '/')) { - current.push_back(ch); - if (ch != '/') - { - continue; - } - if (current.size() <= 1) - { - continue; - } - if (::mkdir(current.c_str(), 0775) != 0 && errno != EEXIST) - { - return false; - } + std::printf("[Packs][Storage] mkdir invalid flash path logical=%s host=%s mount=%s\n", + logical_dir.c_str(), + host_dir.c_str(), + mount_point.c_str()); + return false; } - return ::mkdir(host_dir.c_str(), 0775) == 0 || errno == EEXIST; + std::string current = mount_point; + std::size_t segment_start = mount_point.size(); + while (segment_start < host_dir.size()) + { + while (segment_start < host_dir.size() && host_dir[segment_start] == '/') + { + ++segment_start; + } + if (segment_start == host_dir.size()) + { + break; + } + + const std::size_t segment_end = host_dir.find('/', segment_start); + current.push_back('/'); + current.append(host_dir, + segment_start, + segment_end == std::string::npos ? std::string::npos + : segment_end - segment_start); + + errno = 0; + if (::mkdir(current.c_str(), 0775) != 0 && errno != EEXIST) + { + const int error = errno; + std::printf("[Packs][Storage] mkdir failed logical=%s host=%s path=%s errno=%d (%s)\n", + logical_dir.c_str(), + host_dir.c_str(), + current.c_str(), + error, + std::strerror(error)); + return false; + } + + struct stat st + { + }; + if (::stat(current.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) + { + const int error = errno; + std::printf("[Packs][Storage] mkdir path is not a directory logical=%s host=%s path=%s errno=%d (%s)\n", + logical_dir.c_str(), + host_dir.c_str(), + current.c_str(), + error, + std::strerror(error)); + return false; + } + + if (segment_end == std::string::npos) + { + break; + } + segment_start = segment_end + 1; + } + return true; } bool logical_file_exists(const std::string& logical_path) diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp index c15e9766..a0c3c228 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp @@ -27,7 +27,16 @@ using ::boards::tlora_pager::PagerAudioOwner; using ::boards::tlora_pager::TLoRaPagerBoard; constexpr PagerAudioOwner kOwner = PagerAudioOwner::VoiceMessage; -constexpr float kCaptureGainDb = 24.0F; +// The Pager microphone is wired to the left I2S slot. VMP records that one +// physical source for the entire clip; it never averages a silent right slot +// or switches sources between Codec2 frames. This removes the 6 dB loss of +// the former average while keeping the analogue preamp 6 dB below its prior +// value, so ADC headroom is restored without frame-to-frame gain pumping. +constexpr float kCaptureGainDb = 30.0F; +constexpr uint16_t kPcmUnityGainQ8 = 256U; +constexpr uint16_t kPlaybackPcmGainQ8 = 512U; // 2.00x, matching Walkie RX. +constexpr uint16_t kPcmTargetPeak = 30000U; +constexpr uint16_t kPcmClipThreshold = 32760U; constexpr std::size_t kAudioLevelLogIntervalFrames = 25U; TLoRaPagerBoard* pagerBoard() @@ -106,15 +115,91 @@ struct PcmLevel { uint16_t peak = 0U; uint16_t rms = 0U; + uint16_t clipped_samples = 0U; }; struct CapturePcmLevel { - PcmLevel left{}; - PcmLevel right{}; + PcmLevel input{}; PcmLevel mono{}; }; +struct PcmGainFrameStats +{ + uint16_t applied_gain_q8 = kPcmUnityGainQ8; + uint16_t input_peak = 0U; + uint16_t output_peak = 0U; + uint16_t input_clipped_samples = 0U; + uint16_t output_clipped_samples = 0U; +}; + +uint16_t sampleMagnitude(int16_t sample) +{ + const int32_t value = sample; + return static_cast(value < 0 ? -value : value); +} + +uint16_t boundedPcmGainQ8(uint16_t input_peak, uint16_t requested_gain_q8) +{ + if (input_peak == 0U) + { + return requested_gain_q8; + } + + const uint32_t safe_gain_q8 = + (static_cast(kPcmTargetPeak) * kPcmUnityGainQ8) / input_peak; + if (safe_gain_q8 < kPcmUnityGainQ8) + { + return kPcmUnityGainQ8; + } + return safe_gain_q8 < requested_gain_q8 + ? static_cast(safe_gain_q8) + : requested_gain_q8; +} + +PcmGainFrameStats applyBoundedPcmGain(int16_t* samples, + std::size_t sample_count, + uint16_t requested_gain_q8) +{ + PcmGainFrameStats stats{}; + if (!samples || sample_count == 0U) + { + return stats; + } + + for (std::size_t index = 0U; index < sample_count; ++index) + { + const uint16_t magnitude = sampleMagnitude(samples[index]); + if (magnitude > stats.input_peak) + { + stats.input_peak = magnitude; + } + if (magnitude >= kPcmClipThreshold) + { + ++stats.input_clipped_samples; + } + } + + stats.applied_gain_q8 = boundedPcmGainQ8(stats.input_peak, requested_gain_q8); + for (std::size_t index = 0U; index < sample_count; ++index) + { + const int32_t scaled = + (static_cast(samples[index]) * stats.applied_gain_q8) / + static_cast(kPcmUnityGainQ8); + if (scaled > 32767 || scaled < -32768) + { + ++stats.output_clipped_samples; + } + samples[index] = clampToInt16(scaled); + const uint16_t magnitude = sampleMagnitude(samples[index]); + if (magnitude > stats.output_peak) + { + stats.output_peak = magnitude; + } + } + return stats; +} + PcmLevel measurePcmLevel(const int16_t* samples, std::size_t sample_count, std::size_t stride = 1U, @@ -122,6 +207,7 @@ PcmLevel measurePcmLevel(const int16_t* samples, { uint32_t peak = 0U; uint64_t sum_of_squares = 0U; + uint16_t clipped_samples = 0U; for (std::size_t index = 0U; index < sample_count; ++index) { const int32_t sample = samples[index * stride + offset]; @@ -131,19 +217,17 @@ PcmLevel measurePcmLevel(const int16_t* samples, { peak = magnitude; } + if (magnitude >= kPcmClipThreshold) + { + ++clipped_samples; + } sum_of_squares += static_cast(sample * sample); } const float mean_square = static_cast(sum_of_squares) / static_cast(sample_count); return {static_cast(peak), - static_cast(sqrtf(mean_square) + 0.5F)}; -} - -CapturePcmLevel measureCapturePcmLevel(const int16_t* stereo, const int16_t* mono) -{ - return {measurePcmLevel(stereo, kCodec2SamplesPerFrame, kHardwareChannels, 0U), - measurePcmLevel(stereo, kCodec2SamplesPerFrame, kHardwareChannels, 1U), - measurePcmLevel(mono, kCodec2SamplesPerFrame)}; + static_cast(sqrtf(mean_square) + 0.5F), + clipped_samples}; } bool shouldLogPcmLevel(std::size_t frame_index, std::size_t frame_count) @@ -154,29 +238,27 @@ bool shouldLogPcmLevel(std::size_t frame_index, std::size_t frame_count) completed_frames == frame_count; } -void logCapturePcmLevel(std::size_t frame_index, const CapturePcmLevel& level) +void logCapturePcmLevel(std::size_t frame_index, + const CapturePcmLevel& level, + const PcmGainFrameStats& gain) { - Serial.printf("[VMP][AUDIO] level capture frame=%u left_peak=%u left_rms=%u " - "right_peak=%u right_rms=%u mono_peak=%u mono_rms=%u\n", + Serial.printf("[VMP][AUDIO] level capture frame=%u source=left_mono " + "input_peak=%u input_rms=%u input_raw_clip=%u " + "mono_peak=%u mono_rms=%u " + "encode_gain_x100=%u mono_input_peak=%u mono_output_peak=%u " + "mono_input_clip=%u mono_output_clip=%u\n", static_cast(frame_index + 1U), - static_cast(level.left.peak), - static_cast(level.left.rms), - static_cast(level.right.peak), - static_cast(level.right.rms), + static_cast(level.input.peak), + static_cast(level.input.rms), + static_cast(level.input.clipped_samples), static_cast(level.mono.peak), - static_cast(level.mono.rms)); -} - -void logPlaybackPcmLevel(std::size_t frame_index, - const PcmLevel& level, - uint8_t output_volume) -{ - Serial.printf("[VMP][AUDIO] level playback frame=%u mono_peak=%u mono_rms=%u " - "output_volume=%u\n", - static_cast(frame_index + 1U), - static_cast(level.peak), - static_cast(level.rms), - static_cast(output_volume)); + static_cast(level.mono.rms), + static_cast((gain.applied_gain_q8 * 100U) / + kPcmUnityGainQ8), + static_cast(gain.input_peak), + static_cast(gain.output_peak), + static_cast(gain.input_clipped_samples), + static_cast(gain.output_clipped_samples)); } } // namespace @@ -185,8 +267,104 @@ struct PagerCodec2Audio::FrameScratch { int16_t stereo[kCodec2SamplesPerFrame * kHardwareChannels] = {}; int16_t mono[kCodec2SamplesPerFrame] = {}; + + struct PcmGainSummary + { + uint32_t frames = 0U; + uint32_t samples = 0U; + uint32_t gain_q8_sum = 0U; + uint32_t input_clipped_samples = 0U; + uint32_t output_clipped_samples = 0U; + + void observe(const PcmGainFrameStats& frame, std::size_t sample_count) + { + ++frames; + samples += static_cast(sample_count); + gain_q8_sum += frame.applied_gain_q8; + input_clipped_samples += frame.input_clipped_samples; + output_clipped_samples += frame.output_clipped_samples; + } + + uint16_t averageGainX100() const + { + if (frames == 0U) + { + return 0U; + } + return static_cast((gain_q8_sum * 100U) / + (frames * kPcmUnityGainQ8)); + } + }; + + PcmGainSummary capture_gain{}; + PcmGainSummary playback_gain{}; + CapturePcmLevel capture_level{}; + PcmGainFrameStats frame_gain{}; + uint32_t capture_input_clipped_samples = 0U; + + void clearTelemetry() + { + capture_gain = {}; + playback_gain = {}; + capture_level = {}; + frame_gain = {}; + capture_input_clipped_samples = 0U; + } }; +namespace +{ + +template +void logPlaybackPcmLevel(std::size_t frame_index, + uint8_t output_volume, + const Scratch* scratch) +{ + const PcmLevel& level = scratch->capture_level.mono; + const PcmGainFrameStats& gain = scratch->frame_gain; + Serial.printf("[VMP][AUDIO] level playback frame=%u mono_peak=%u mono_rms=%u " + "output_volume=%u decode_gain_x100=%u mono_input_peak=%u " + "mono_output_peak=%u mono_input_clip=%u mono_output_clip=%u\n", + static_cast(frame_index + 1U), + static_cast(level.peak), + static_cast(level.rms), + static_cast(output_volume), + static_cast((gain.applied_gain_q8 * 100U) / + kPcmUnityGainQ8), + static_cast(gain.input_peak), + static_cast(gain.output_peak), + static_cast(gain.input_clipped_samples), + static_cast(gain.output_clipped_samples)); +} + +template +void logCaptureGainSummary(const GainSummary& gain, uint32_t input_clipped_samples) +{ + Serial.printf("[VMP][AUDIO] capture summary analog_gain_db=30 source=left_mono " + "encode_gain_avg_x100=%u samples=%lu mono_input_clip=%lu " + "mono_output_clip=%lu input_raw_clip=%lu\n", + static_cast(gain.averageGainX100()), + static_cast(gain.samples), + static_cast(gain.input_clipped_samples), + static_cast(gain.output_clipped_samples), + static_cast(input_clipped_samples)); +} + +template +void logPlaybackGainSummary(const GainSummary& gain, uint8_t output_volume) +{ + Serial.printf("[VMP][AUDIO] playback summary output_volume=%u " + "decode_gain_avg_x100=%u samples=%lu mono_input_clip=%lu " + "mono_output_clip=%lu\n", + static_cast(output_volume), + static_cast(gain.averageGainX100()), + static_cast(gain.samples), + static_cast(gain.input_clipped_samples), + static_cast(gain.output_clipped_samples)); +} + +} // namespace + bool PagerCodec2Audio::isSupported() const { return pagerBoard() != nullptr; @@ -201,7 +379,8 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) { clearEncodedMedia(); const uint32_t started_ms = millis(); - Serial.printf("[VMP][AUDIO] capture begin max_ms=5000 stop_hook=%u\n", + Serial.printf("[VMP][AUDIO] capture begin max_ms=5000 stop_hook=%u " + "analog_gain_db=30 source=left_mono encode_gain_x100=100\n", stop_requested ? 1U : 0U); TLoRaPagerBoard* const board = pagerBoard(); if (!board || !acquireFrameScratch()) @@ -215,6 +394,7 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) Serial.printf("[VMP][AUDIO] capture rejected reason=audio_busy\n"); return CaptureResult::AudioBusy; } + frame_scratch_->clearTelemetry(); CODEC2* const encoder = codec2_create(CODEC2_MODE_1300); const int sample_count = encoder ? codec2_samples_per_frame(encoder) : 0; @@ -233,7 +413,6 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) } CaptureResult result = CaptureResult::Complete; - CapturePcmLevel last_capture_level{}; bool has_capture_level = false; bool stopped_by_release = false; for (std::size_t frame = 0; frame < kCodec2FramesPerMessage; ++frame) @@ -254,12 +433,26 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) break; } mixCaptureToMono(); - last_capture_level = measureCapturePcmLevel(frame_scratch_->stereo, - frame_scratch_->mono); + frame_scratch_->capture_input_clipped_samples += + frame_scratch_->capture_level.input.clipped_samples; + // Keep recorded speech at one fixed gain. Peak-dependent gain per + // Codec2 frame causes audible pumping and raises quiet-frame noise. + // These statistics are telemetry only; they do not alter the PCM. + frame_scratch_->frame_gain = {}; + frame_scratch_->frame_gain.input_peak = frame_scratch_->capture_level.mono.peak; + frame_scratch_->frame_gain.output_peak = frame_scratch_->capture_level.mono.peak; + frame_scratch_->frame_gain.input_clipped_samples = + frame_scratch_->capture_level.mono.clipped_samples; + frame_scratch_->frame_gain.output_clipped_samples = + frame_scratch_->capture_level.mono.clipped_samples; + frame_scratch_->capture_gain.observe(frame_scratch_->frame_gain, + kCodec2SamplesPerFrame); has_capture_level = true; if (shouldLogPcmLevel(frame, kCodec2FramesPerMessage)) { - logCapturePcmLevel(frame, last_capture_level); + logCapturePcmLevel(frame, + frame_scratch_->capture_level, + frame_scratch_->frame_gain); } codec2_encode(encoder, encoded_media_ + encoded_media_size_, @@ -279,7 +472,14 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) const std::size_t captured_frame_count = encoded_media_size_ / kCodec2BytesPerFrame; if (has_capture_level && captured_frame_count % kAudioLevelLogIntervalFrames != 0U) { - logCapturePcmLevel(captured_frame_count - 1U, last_capture_level); + logCapturePcmLevel(captured_frame_count - 1U, + frame_scratch_->capture_level, + frame_scratch_->frame_gain); + } + if (frame_scratch_->capture_gain.frames != 0U) + { + logCaptureGainSummary(frame_scratch_->capture_gain, + frame_scratch_->capture_input_clipped_samples); } releaseFrameScratch(); const std::size_t encoded_size = encoded_media_size_; @@ -342,6 +542,7 @@ PlaybackResult PagerCodec2Audio::play(const uint8_t* encoded_media, } const uint8_t output_volume = volume_percent > 100U ? 100U : volume_percent; (void)board->audioSetVolume(kOwner, output_volume); + frame_scratch_->clearTelemetry(); CODEC2* const decoder = codec2_create(CODEC2_MODE_1300); const int sample_count = decoder ? codec2_samples_per_frame(decoder) : 0; @@ -367,11 +568,16 @@ PlaybackResult PagerCodec2Audio::play(const uint8_t* encoded_media, codec2_decode(decoder, frame_scratch_->mono, const_cast(encoded_media + offset)); - const PcmLevel level = + frame_scratch_->frame_gain = applyBoundedPcmGain(frame_scratch_->mono, + kCodec2SamplesPerFrame, + kPlaybackPcmGainQ8); + frame_scratch_->playback_gain.observe(frame_scratch_->frame_gain, + kCodec2SamplesPerFrame); + frame_scratch_->capture_level.mono = measurePcmLevel(frame_scratch_->mono, kCodec2SamplesPerFrame); if (shouldLogPcmLevel(frame_index, frame_count)) { - logPlaybackPcmLevel(frame_index, level, output_volume); + logPlaybackPcmLevel(frame_index, output_volume, frame_scratch_); } duplicatePlaybackToStereo(); if (!writePlaybackFrame()) @@ -383,6 +589,10 @@ PlaybackResult PagerCodec2Audio::play(const uint8_t* encoded_media, codec2_destroy(decoder); board->closeAudioSession(kOwner); + if (frame_scratch_->playback_gain.frames != 0U) + { + logPlaybackGainSummary(frame_scratch_->playback_gain, output_volume); + } releaseFrameScratch(); return result; } @@ -437,12 +647,17 @@ bool PagerCodec2Audio::writePlaybackFrame() void PagerCodec2Audio::mixCaptureToMono() { + CapturePcmLevel& level = frame_scratch_->capture_level; + level.input = measurePcmLevel(frame_scratch_->stereo, + kCodec2SamplesPerFrame, + kHardwareChannels, + 0U); for (std::size_t index = 0U; index < kCodec2SamplesPerFrame; ++index) { - const int32_t left = frame_scratch_->stereo[index * kHardwareChannels]; - const int32_t right = frame_scratch_->stereo[index * kHardwareChannels + 1U]; - frame_scratch_->mono[index] = clampToInt16((left + right) / 2); + frame_scratch_->mono[index] = + frame_scratch_->stereo[index * kHardwareChannels]; } + level.mono = measurePcmLevel(frame_scratch_->mono, kCodec2SamplesPerFrame); } void PagerCodec2Audio::duplicatePlaybackToStereo() diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp index aeecdd1f..7da73444 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp @@ -17,7 +17,9 @@ #include "chat/infra/voice/vmp_mqtt_transport.h" #include "chat/infra/voice/vmp_receive_block.h" #include "platform/esp/arduino_common/chat/infra/store/message_attachment_store.h" +#include "platform/esp/common/memory_budget.h" #include "sys/clock.h" +#include "sys/event_bus.h" #include @@ -58,14 +60,17 @@ constexpr uint8_t kReadyProbeCount = 3U; // only for an active record/play operation and are released by vTaskDelete. constexpr uint32_t kOutboundTaskStackBytes = 8U * 1024U; constexpr UBaseType_t kOutboundTaskPriority = 4U; -#if defined(ARDUINO_T_DECK) -// T-Deck is playback-only: Codec2 state and PCM are PSRAM-backed and its I2S -// output path has no Pager codec/I2C capture chain. Keep this transient worker -// aligned with T-Deck's existing 4 KiB audio task rather than reserving the -// Pager's capture-sized 8 KiB internal-RAM stack. -constexpr uint32_t kPlaybackTaskStackBytes = 4U * 1024U; -#else constexpr uint32_t kPlaybackTaskStackBytes = 8U * 1024U; +#if defined(ARDUINO_T_DECK) +// Codec2's transient state and PCM live in PSRAM, but the decoder's nested +// call frames remain on this FreeRTOS stack. The 4 KiB T-Deck variant trips +// its canary on the first Codec2-1300 decode; use the Pager-proven 8 KiB +// budget only for the short-lived playback worker. Reserve an extra KiB in +// admission for the task control block and heap-allocation overhead, while +// preserving the existing 48 KiB internal-RAM floor for the rest of the UI. +constexpr std::size_t kTDeckPlaybackTaskInternalReservation = + kPlaybackTaskStackBytes + 1024U; +constexpr std::size_t kTDeckPlaybackTaskInternalFloor = 48U * 1024U; #endif constexpr UBaseType_t kPlaybackTaskPriority = 3U; constexpr uint32_t kPersistentInboxRetryMs = 5000U; @@ -112,6 +117,22 @@ uint32_t voiceTimestampSeconds() return ::sys::epoch_seconds_now(); } +void publishIncomingVoiceMessage(uint8_t conversation_channel, uint64_t local_id) +{ + // The inbox owns the metadata and media. Send the standard incoming-chat + // event only after releasing its mutex, and never wait on the UI from an + // RF/MQTT receive path. The voice variant carries just an identifier; + // it never duplicates Codec2 media or a UI projection buffer. + sys::Event* const event = + new (std::nothrow) sys::ChatVoiceMessageEvent(conversation_channel, local_id); + const bool queued = event && sys::EventBus::publish(event, 0U); + Serial.printf("[VMP][RX] chat_new_voice local_id=%llu channel=%u queued=%u pending=%u\n", + static_cast(local_id), + static_cast(conversation_channel), + queued ? 1U : 0U, + static_cast(sys::EventBus::pendingCount())); +} + bool profileFor(const vmp::ControlFrame& control, radio::PhyProfile* out_profile) { if (!out_profile || control.phy_profile_id != kDefaultPhyProfile || @@ -436,6 +457,27 @@ class PagerReceiveSession final return false; } +#if defined(ARDUINO_T_DECK) + // The task stack is allocated by xTaskCreatePinnedToCore below. Check + // its full internal-RAM budget before copying media or creating the + // task, so playback is skipped rather than putting UI allocations + // under pressure. + if (!::platform::esp::common::memory::admit( + "vmp_play_task", + kTDeckPlaybackTaskInternalReservation, + 0U, + 0U, + kTDeckPlaybackTaskInternalFloor, + 0U)) + { + Serial.printf("[VMP][PLAY] rejected local_id=%llu reason=memory_budget " + "stack_bytes=%u\n", + static_cast(local_id), + static_cast(kPlaybackTaskStackBytes)); + return false; + } +#endif + if (!lockState()) { return false; @@ -652,22 +694,32 @@ class PagerReceiveSession final envelope, envelope_len, self_node_id_, contacts_); bool stored = accepted == vmp::MqttTransferResult::Accepted || accepted == vmp::MqttTransferResult::Duplicate; + uint64_t completed_local_id = 0U; + uint8_t completed_conversation_channel = 0U; if (accepted == vmp::MqttTransferResult::Complete) { std::size_t media_len = 0U; + const vmp::ControlFrame& completed_control = media_->mqtt_receive.control(); + completed_conversation_channel = completed_control.conversation_channel; const bool recovered = media_->mqtt_receive.recover(media_->mqtt_received_media, sizeof(media_->mqtt_received_media), &media_len); const bool stored_voice = - recovered && storeCompletedVoice(media_->mqtt_receive.control(), + recovered && storeCompletedVoice(completed_control, media_->mqtt_received_media, media_len, - voiceTimestampSeconds()); + voiceTimestampSeconds(), + &completed_local_id); secureClear(media_->mqtt_received_media, sizeof(media_->mqtt_received_media)); media_->mqtt_receive.clear(); stored = stored_voice; } unlockState(); + if (stored && completed_local_id != 0U) + { + publishIncomingVoiceMessage(completed_conversation_channel, + completed_local_id); + } return stored; } @@ -1793,12 +1845,20 @@ class PagerReceiveSession final { return false; } + const uint8_t completed_conversation_channel = incoming_control_.conversation_channel; + uint64_t completed_local_id = 0U; const bool stored = storeCompletedVoice(incoming_control_, media_->received_media, media_len, - voiceTimestampSeconds()); + voiceTimestampSeconds(), + &completed_local_id); secureClear(media_->received_media, sizeof(media_->received_media)); unlockState(); + if (stored && completed_local_id != 0U) + { + publishIncomingVoiceMessage(completed_conversation_channel, + completed_local_id); + } return stored; } @@ -1954,8 +2014,13 @@ class PagerReceiveSession final bool storeCompletedVoice(const vmp::ControlFrame& control, const uint8_t* encoded_media, std::size_t encoded_media_len, - uint32_t received_at_seconds) + uint32_t received_at_seconds, + uint64_t* out_local_id) { + if (out_local_id) + { + *out_local_id = 0U; + } if (!media_ || !inbox_ready_) { return false; @@ -1986,6 +2051,10 @@ class PagerReceiveSession final if (!requires_durable_attachment_store_) { + if (out_local_id) + { + *out_local_id = local_id; + } Serial.printf("[VMP][RX] inbox stored volatile bytes=%u\n", static_cast(encoded_media_len)); return true; @@ -2005,6 +2074,10 @@ class PagerReceiveSession final } else { + if (out_local_id) + { + *out_local_id = local_id; + } Serial.printf("[VMP][RX] inbox durable_commit local_id=%llu bytes=%u\n", static_cast(local_id), static_cast(encoded_media_len)); diff --git a/platform/esp/boards/include/display/DisplayInterface.h b/platform/esp/boards/include/display/DisplayInterface.h index 6b567aa2..590fd9b1 100644 --- a/platform/esp/boards/include/display/DisplayInterface.h +++ b/platform/esp/boards/include/display/DisplayInterface.h @@ -125,10 +125,12 @@ class LilyGo_Display virtual void requestFullRefresh() {} bool needFullRefresh() { return _full_refresh; } virtual bool useDMA() { return false; } +#if defined(ARDUINO_T_DECK_PRO) // Board-specific display work that has been safely copied out of the LVGL - // draw buffer can be completed here. Most displays transfer synchronously, - // so their default implementation intentionally does nothing. + // draw buffer can be completed here. The EPD needs this deferred commit; + // do not grow the display vtable on synchronous display targets. virtual void serviceDisplay(uint32_t now_ms) { (void)now_ms; } +#endif protected: uint16_t _offset_x; diff --git a/platform/esp/idf_common/src/gps_runtime.cpp b/platform/esp/idf_common/src/gps_runtime.cpp index d42d6fb7..1a0c7c25 100644 --- a/platform/esp/idf_common/src/gps_runtime.cpp +++ b/platform/esp/idf_common/src/gps_runtime.cpp @@ -32,6 +32,33 @@ constexpr std::time_t kMinValidEpochSeconds = 1577836800; // 2020-01-01 UTC constexpr uint32_t kProbeWarmupMs = 1500; constexpr uint32_t kProbeListenMs = 3500; constexpr uint32_t kNoDataWarnMs = 5000; +constexpr size_t kRawDiagnosticSampleBytes = 16; +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +constexpr uint32_t kP4GnssFallbackBaud = 9600; +constexpr uint32_t kP4GnssFallbackWakeCycleMs = 100; +#endif + +enum class NmeaParseResult : uint8_t +{ + Accepted = 0, + Unsupported, + MissingDollar, + InvalidChecksum, + Malformed, +}; + +struct GpsRxDiagnostics +{ + uint64_t discarded_prefix_bytes = 0; + uint32_t completed_lines = 0; + uint32_t accepted_sentences = 0; + uint32_t unsupported_sentences = 0; + uint32_t invalid_checksums = 0; + uint32_t malformed_sentences = 0; + uint32_t line_overflows = 0; + std::array raw_sample{}; + size_t raw_sample_len = 0; +}; enum class CollectorSlot : size_t { @@ -535,16 +562,20 @@ void parse_gsv_locked(const char* talker, const std::array& f merge_sats_locked(); } -void parse_sentence_locked(const char* sentence, uint32_t ts) +NmeaParseResult parse_sentence_locked(const char* sentence, uint32_t ts) { - if (!sentence || sentence[0] != '$' || !verify_checksum(sentence)) return; + if (!sentence || sentence[0] != '$') return NmeaParseResult::MissingDollar; + if (!verify_checksum(sentence)) return NmeaParseResult::InvalidChecksum; char working[kLineBufferSize] = {}; std::strncpy(working, sentence + 1, sizeof(working) - 1); char* star = std::strchr(working, '*'); if (star) *star = '\0'; std::array fields{}; std::size_t count = split_fields(working, fields); - if (count == 0 || !fields[0] || std::strlen(fields[0]) < 5) return; + if (count == 0 || !fields[0] || std::strlen(fields[0]) < 5) + { + return NmeaParseResult::Malformed; + } char talker[3] = {fields[0][0], fields[0][1], '\0'}; const char* type = fields[0] + std::strlen(fields[0]) - 3; s_runtime.last_rx_ms = ts; @@ -557,11 +588,85 @@ void parse_sentence_locked(const char* sentence, uint32_t ts) else if (std::strcmp(type, "GGA") == 0) parse_gga_locked(fields, count, ts); else if (std::strcmp(type, "GSA") == 0) parse_gsa_locked(fields, count); else if (std::strcmp(type, "GSV") == 0) parse_gsv_locked(talker, fields, count); + else return NmeaParseResult::Unsupported; + return NmeaParseResult::Accepted; } -void append_bytes_and_process(const uint8_t* buffer, int length, char* line_buffer, std::size_t* line_length) +void record_nmea_parse_result(GpsRxDiagnostics* diagnostics, NmeaParseResult result) +{ + if (!diagnostics) return; + switch (result) + { + case NmeaParseResult::Accepted: + ++diagnostics->accepted_sentences; + break; + case NmeaParseResult::Unsupported: + ++diagnostics->unsupported_sentences; + break; + case NmeaParseResult::InvalidChecksum: + ++diagnostics->invalid_checksums; + break; + case NmeaParseResult::Malformed: + case NmeaParseResult::MissingDollar: + ++diagnostics->malformed_sentences; + break; + } +} + +void save_raw_diagnostic_sample(GpsRxDiagnostics* diagnostics, const uint8_t* buffer, int length) +{ + if (!diagnostics || !buffer || length <= 0) return; + diagnostics->raw_sample_len = + std::min(static_cast(length), diagnostics->raw_sample.size()); + std::memcpy(diagnostics->raw_sample.data(), buffer, diagnostics->raw_sample_len); +} + +void format_raw_diagnostic_sample(const GpsRxDiagnostics& diagnostics, + char* output, + std::size_t output_size) +{ + if (!output || output_size == 0) return; + output[0] = '\0'; + std::size_t used = 0; + for (std::size_t i = 0; i < diagnostics.raw_sample_len && used + 3 < output_size; ++i) + { + const int written = std::snprintf(output + used, + output_size - used, + "%02X%s", + static_cast(diagnostics.raw_sample[i]), + i + 1 == diagnostics.raw_sample_len ? "" : " "); + if (written <= 0) break; + used += static_cast(written); + } +} + +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +bool raw_diagnostic_sample_is_all_zero(const GpsRxDiagnostics& diagnostics) +{ + if (diagnostics.raw_sample_len == 0) + { + return false; + } + + for (std::size_t index = 0; index < diagnostics.raw_sample_len; ++index) + { + if (diagnostics.raw_sample[index] != 0) + { + return false; + } + } + return true; +} +#endif + +void append_bytes_and_process(const uint8_t* buffer, + int length, + char* line_buffer, + std::size_t* line_length, + GpsRxDiagnostics* diagnostics) { if (!buffer || length <= 0 || !line_buffer || !line_length) return; + save_raw_diagnostic_sample(diagnostics, buffer, length); for (int i = 0; i < length; ++i) { char ch = static_cast(buffer[i]); @@ -571,14 +676,27 @@ void append_bytes_and_process(const uint8_t* buffer, int length, char* line_buff { line_buffer[*line_length] = '\0'; std::lock_guard lock(s_mutex); - parse_sentence_locked(line_buffer, now_ms()); + const NmeaParseResult result = parse_sentence_locked(line_buffer, now_ms()); + if (diagnostics) + { + ++diagnostics->completed_lines; + record_nmea_parse_result(diagnostics, result); + } *line_length = 0; } continue; } - if (*line_length == 0 && ch != '$') continue; + if (*line_length == 0 && ch != '$') + { + if (diagnostics) ++diagnostics->discarded_prefix_bytes; + continue; + } if (*line_length + 1 < kLineBufferSize) line_buffer[(*line_length)++] = ch; - else *line_length = 0; + else + { + if (diagnostics) ++diagnostics->line_overflows; + *line_length = 0; + } } } @@ -664,6 +782,10 @@ void worker_task(void*) uint8_t rx_buffer[kRxBufferSize] = {}; uint64_t raw_rx_bytes = 0; uint32_t last_raw_rx_ms = 0; + GpsRxDiagnostics rx_diagnostics{}; +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + bool p4_auto_baud_fallback_attempted = false; +#endif while (true) { @@ -678,7 +800,11 @@ void worker_task(void*) { raw_rx_bytes += static_cast(read_len); last_raw_rx_ms = now_ms(); - append_bytes_and_process(rx_buffer, read_len, line_buffer, &line_length); + append_bytes_and_process(rx_buffer, + read_len, + line_buffer, + &line_length, + &rx_diagnostics); } bool should_exit = false; @@ -688,6 +814,9 @@ void worker_task(void*) uint32_t last_rx_ms = 0; uint32_t last_fix_ms = 0; uint32_t now = now_ms(); +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + bool switch_to_p4_fallback_baud = false; +#endif { std::lock_guard lock(s_mutex); enabled = s_runtime.enabled; @@ -706,17 +835,74 @@ void worker_task(void*) else if ((now - last_rx_ms) >= kNoDataWarnMs && s_runtime.last_no_data_log_ms + kNoDataWarnMs <= now) { const auto gps_uart_log = gps_uart_pins(); + char raw_sample[3 * kRawDiagnosticSampleBytes] = {}; + format_raw_diagnostic_sample(rx_diagnostics, raw_sample, sizeof(raw_sample)); ESP_LOGW(kTag, - "GNSS no NMEA data yet: port=%d tx=%d rx=%d mode=%u raw_bytes=%llu last_raw_ms=%u", + "GNSS parser has no valid NMEA: port=%d tx=%d rx=%d mode=%u raw_bytes=%llu last_raw_ms=%u lines=%lu valid=%lu unsupported=%lu bad_checksum=%lu malformed=%lu prefix_bytes=%llu overflow=%lu partial_len=%u raw_hex=%s", gps_uart_log.port, gps_uart_log.tx, gps_uart_log.rx, s_runtime.gnss_mode, static_cast(raw_rx_bytes), - last_raw_rx_ms); + last_raw_rx_ms, + static_cast(rx_diagnostics.completed_lines), + static_cast(rx_diagnostics.accepted_sentences), + static_cast(rx_diagnostics.unsupported_sentences), + static_cast(rx_diagnostics.invalid_checksums), + static_cast(rx_diagnostics.malformed_sentences), + static_cast(rx_diagnostics.discarded_prefix_bytes), + static_cast(rx_diagnostics.line_overflows), + static_cast(line_length), + raw_sample[0] != '\0' ? raw_sample : "none"); +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // A P4 receiver left at a factory L76K rate can look like a + // continuously low UART when sampled at the board's 115200 + // default. Only auto-fallback when Settings requested the + // board default (baud=0); an explicit user selection always + // remains authoritative. + if (!p4_auto_baud_fallback_attempted && + s_runtime.receiver_init_config.baud == 0 && + raw_rx_bytes >= kRxBufferSize && + raw_diagnostic_sample_is_all_zero(rx_diagnostics)) + { + p4_auto_baud_fallback_attempted = true; + switch_to_p4_fallback_baud = true; + } +#endif s_runtime.last_no_data_log_ms = now; } } +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + if (switch_to_p4_fallback_baud) + { + ESP_LOGW(kTag, + "P4 GNSS auto-baud fallback: raw UART stayed at 0x00 with Settings baud=Auto; cycling wake then trying %lu", + static_cast(kP4GnssFallbackBaud)); + teardown_uart_hardware(); + vTaskDelay(pdMS_TO_TICKS(kP4GnssFallbackWakeCycleMs)); + if (::boards::t_display_p4::TDisplayP4Board::instance().prepareGpsRuntime( + kP4GnssFallbackBaud)) + { + ESP_LOGI(kTag, + "P4 GNSS auto-baud fallback active: port=%d tx=%d rx=%d baud=%lu", + gps_uart.port, + gps_uart.tx, + gps_uart.rx, + static_cast(kP4GnssFallbackBaud)); + std::memset(line_buffer, 0, sizeof(line_buffer)); + line_length = 0; + raw_rx_bytes = 0; + last_raw_rx_ms = 0; + rx_diagnostics = GpsRxDiagnostics{}; + } + else + { + ESP_LOGE(kTag, "P4 GNSS auto-baud fallback UART setup failed"); + (void)::boards::t_display_p4::TDisplayP4Board::instance().prepareGpsRuntime(); + } + continue; + } +#endif if (should_exit) break; } diff --git a/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp b/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp index a34c9849..8e644026 100644 --- a/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp @@ -39,22 +39,46 @@ bool supports_receiver_baud_setting() bool supports_receiver_init_policy_settings() { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // P4 currently applies the selected UART baud on the next GNSS lifecycle, + // but does not send receiver profile/policy commands. Do not show a + // settings group whose values cannot reach the GNSS receiver. + return false; +#else return true; +#endif } bool supports_gnss_runtime_settings() { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // The P4 runtime parses the receiver's current NMEA stream; it does not + // yet program constellation mode or satellite mask commands. + return false; +#else return true; +#endif } bool supports_collection_interval_setting() { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // There is no P4 receiver command or sampling scheduler behind this UI + // setting, so exposing it makes the configuration misleading. + return false; +#else return true; +#endif } bool supports_external_nmea_output_setting() { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // P4 has no external NMEA output transport wired to this setting. + return false; +#else return true; +#endif } bool supports_altitude_reference_setting() diff --git a/platform/esp/idf_common/src/screen_sleep.cpp b/platform/esp/idf_common/src/screen_sleep.cpp index 2a3558ea..63adf0d5 100644 --- a/platform/esp/idf_common/src/screen_sleep.cpp +++ b/platform/esp/idf_common/src/screen_sleep.cpp @@ -302,11 +302,11 @@ uint16_t timeout_secs() bool supports_app_timeout_setting() { -#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) - return false; -#else + // Every ESP target that links this runtime has a functional screen-sleep + // path. Keep the user-selectable timeout available on P4 as it is on + // Pager and T-Deck instead of silently falling back to the persisted + // default. return true; -#endif } void set_timeout_ms(uint32_t timeout_ms) diff --git a/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp b/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp index 344b92d5..c0b21bc9 100644 --- a/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp +++ b/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp @@ -318,6 +318,23 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface { return false; } +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + if (canUseMultiSectorDma(dst, ns)) + { + const esp_err_t err = sdmmc_read_sectors(card_, dst, sector, ns); + if (err != ESP_OK) + { + ESP_LOGW(kTag, + "raw multi-read sector=%lu count=%u failed: %s", + static_cast(sector), + static_cast(ns), + esp_err_to_name(err)); + return false; + } + return true; + } +#endif for (size_t i = 0; i < ns; ++i) { if (!readSector(sector + static_cast(i), @@ -361,6 +378,23 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface { return false; } +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + if (canUseMultiSectorDma(src, ns)) + { + const esp_err_t err = sdmmc_write_sectors(card_, src, sector, ns); + if (err != ESP_OK) + { + ESP_LOGW(kTag, + "raw multi-write sector=%lu count=%u failed: %s", + static_cast(sector), + static_cast(ns), + esp_err_to_name(err)); + return false; + } + return true; + } +#endif for (size_t i = 0; i < ns; ++i) { if (!writeSector(sector + static_cast(i), @@ -373,6 +407,24 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface } private: +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ + defined(TRAIL_MATE_ESP_BOARD_TAB5) + bool canUseMultiSectorDma(const void* buffer, size_t sector_count) const + { + if (!card_ || !buffer || sector_count < 2 || + sector_count > (SIZE_MAX / kSdSectorSize) || + card_->host.check_buffer_alignment == nullptr) + { + return false; + } + + return card_->host.check_buffer_alignment( + card_->host.slot, + buffer, + sector_count * kSdSectorSize); + } +#endif + sdmmc_card_t* card_ = nullptr; }; diff --git a/platform/esp/idf_components/t_display_p4/CMakeLists.txt b/platform/esp/idf_components/t_display_p4/CMakeLists.txt index 79fe0ae1..2fdde7c0 100644 --- a/platform/esp/idf_components/t_display_p4/CMakeLists.txt +++ b/platform/esp/idf_components/t_display_p4/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( SRCS "trail_mate_t_display_p4_runtime.cpp" + "p4_dsi_rotating_presenter.cpp" "t_display_p4_audio_runtime.cpp" "${CMAKE_SOURCE_DIR}/boards/t_display_p4/src/runtime_support.cpp" "hi8561_driver.c" diff --git a/platform/esp/idf_components/t_display_p4/p4_dsi_rotating_presenter.cpp b/platform/esp/idf_components/t_display_p4/p4_dsi_rotating_presenter.cpp new file mode 100644 index 00000000..ae63f200 --- /dev/null +++ b/platform/esp/idf_components/t_display_p4/p4_dsi_rotating_presenter.cpp @@ -0,0 +1,219 @@ +#include "p4_dsi_rotating_presenter.h" + +#include "esp_err.h" +#include "esp_lcd_panel_ops.h" +#include "esp_log.h" + +namespace boards::t_display_p4 +{ +namespace +{ + +constexpr const char* kTag = "p4-dsi-present"; + +} // namespace + +bool P4DsiRotatingPresenter::init(esp_lcd_panel_handle_t panel, + uint32_t physical_hres, + uint32_t physical_vres, + uint8_t bits_per_pixel, + lv_color_format_t color_format, + lv_display_rotation_t rotation) +{ + if (panel == nullptr || physical_hres == 0 || physical_vres == 0 || + (bits_per_pixel != 16 && bits_per_pixel != 24) || + (rotation != LV_DISPLAY_ROTATION_90 && rotation != LV_DISPLAY_ROTATION_270)) + { + return false; + } + + panel_ = panel; + physical_hres_ = physical_hres; + physical_vres_ = physical_vres; + color_format_ = color_format; + rotation_ = rotation; + frame_bytes_ = static_cast(physical_hres_) * physical_vres_ * + (bits_per_pixel / 8U); + + void* first_scanout = nullptr; + void* logical_buffer = nullptr; + void* second_scanout = nullptr; + if (esp_lcd_dpi_panel_get_frame_buffer(panel_, + 3, + &first_scanout, + &logical_buffer, + &second_scanout) != ESP_OK || + first_scanout == nullptr || logical_buffer == nullptr || second_scanout == nullptr) + { + ESP_LOGE(kTag, "failed to acquire the three DSI frame buffers"); + return false; + } + + display_ = lv_display_create(physical_hres_, physical_vres_); + if (display_ == nullptr) + { + ESP_LOGE(kTag, "failed to create LVGL display"); + return false; + } + + scanout_buffers_[0] = first_scanout; + scanout_buffers_[1] = second_scanout; + logical_buffer_ = logical_buffer; + front_buffer_.store(first_scanout, std::memory_order_release); + + lv_display_set_color_format(display_, color_format_); + lv_display_set_buffers(display_, + logical_buffer_, + nullptr, + frame_bytes_, + LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_user_data(display_, this); + lv_display_set_flush_cb(display_, flush_callback); + + esp_lcd_dpi_panel_event_callbacks_t callbacks{}; + callbacks.on_refresh_done = refresh_done_callback; + if (esp_lcd_dpi_panel_register_event_callbacks(panel_, &callbacks, this) != ESP_OK) + { + ESP_LOGE(kTag, "failed to register DSI refresh callback"); + return false; + } + + lv_display_set_rotation(display_, rotation_); + ESP_LOGI(kTag, + "ready: three panel-owned buffers, logical=%p front=%p back=%p bytes=%u rotation=software", + logical_buffer_, + scanout_buffers_[0], + scanout_buffers_[1], + static_cast(frame_bytes_)); + return true; +} + +lv_display_t* P4DsiRotatingPresenter::display() const +{ + return display_; +} + +std::size_t P4DsiRotatingPresenter::frame_bytes() const +{ + return frame_bytes_; +} + +void P4DsiRotatingPresenter::flush_callback(lv_display_t* display, + const lv_area_t* area, + uint8_t* pixel_map) +{ + auto* presenter = static_cast(lv_display_get_user_data(display)); + if (presenter == nullptr || !presenter->present_full_frame(area, pixel_map)) + { + // A failed present must not leave the LVGL task permanently blocked. + // The next invalidation retries against the still-valid front buffer. + lv_display_flush_ready(display); + } +} + +bool IRAM_ATTR P4DsiRotatingPresenter::refresh_done_callback( + esp_lcd_panel_handle_t panel, + esp_lcd_dpi_panel_event_data_t* event_data, + void* user_context) +{ + (void)panel; + (void)event_data; + auto* presenter = static_cast(user_context); + if (presenter == nullptr || + !presenter->awaiting_refresh_.exchange(false, std::memory_order_acq_rel)) + { + return false; + } + + void* const completed_front = + presenter->pending_front_buffer_.exchange(nullptr, std::memory_order_acq_rel); + if (completed_front != nullptr) + { + presenter->front_buffer_.store(completed_front, std::memory_order_release); + } + if (presenter->display_ != nullptr) + { + lv_display_flush_ready(presenter->display_); + } + return false; +} + +bool P4DsiRotatingPresenter::present_full_frame(const lv_area_t* area, + uint8_t* pixel_map) +{ + if (area == nullptr || pixel_map == nullptr || display_ == nullptr || panel_ == nullptr || + awaiting_refresh_.load(std::memory_order_acquire)) + { + return false; + } + + const int32_t logical_hres = lv_display_get_horizontal_resolution(display_); + const int32_t logical_vres = lv_display_get_vertical_resolution(display_); + const bool complete_logical_frame = area->x1 == 0 && area->y1 == 0 && + area->x2 == logical_hres - 1 && + area->y2 == logical_vres - 1; + if (!complete_logical_frame) + { + ESP_LOGE(kTag, + "full presenter received partial flush area=(%d,%d)-(%d,%d) expected=%ldx%ld", + area->x1, + area->y1, + area->x2, + area->y2, + static_cast(logical_hres), + static_cast(logical_vres)); + return false; + } + + void* const visible = front_buffer_.load(std::memory_order_acquire); + void* const target = visible == scanout_buffers_[0] ? scanout_buffers_[1] + : scanout_buffers_[0]; + if (target == nullptr) + { + return false; + } + rotate_with_lvgl(target, pixel_map); + + // draw_bitmap sees target as one of the DPI panel frame buffers. It does a + // cache write-back and records the target as the frame selected at the next + // DMA refresh boundary; it does not copy over the visible front buffer. + const esp_err_t err = esp_lcd_panel_draw_bitmap(panel_, + 0, + 0, + static_cast(physical_hres_), + static_cast(physical_vres_), + target); + if (err != ESP_OK) + { + ESP_LOGE(kTag, "failed to submit back frame: %s", esp_err_to_name(err)); + return false; + } + + // A refresh interrupt that occurs just before this store is intentionally + // ignored. The following refresh boundary releases LVGL, which is safe and + // preserves the no-reuse-before-scanout invariant. + pending_front_buffer_.store(target, std::memory_order_release); + awaiting_refresh_.store(true, std::memory_order_release); + return true; +} + +void P4DsiRotatingPresenter::rotate_with_lvgl(void* output_buffer, + const uint8_t* input_buffer) const +{ + const int32_t logical_hres = lv_display_get_horizontal_resolution(display_); + const int32_t logical_vres = lv_display_get_vertical_resolution(display_); + const uint32_t input_stride = + lv_draw_buf_width_to_stride(logical_hres, color_format_); + const uint32_t output_stride = + lv_draw_buf_width_to_stride(static_cast(physical_hres_), color_format_); + lv_draw_sw_rotate(input_buffer, + output_buffer, + logical_hres, + logical_vres, + input_stride, + output_stride, + rotation_, + color_format_); +} + +} // namespace boards::t_display_p4 diff --git a/platform/esp/idf_components/t_display_p4/p4_dsi_rotating_presenter.h b/platform/esp/idf_components/t_display_p4/p4_dsi_rotating_presenter.h new file mode 100644 index 00000000..32a6e266 --- /dev/null +++ b/platform/esp/idf_components/t_display_p4/p4_dsi_rotating_presenter.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +#include "esp_lcd_mipi_dsi.h" +#include "lvgl.h" + +namespace boards::t_display_p4 +{ + +// Presents a landscape LVGL scene to the portrait-native DSI panel without +// ever modifying the frame that the DSI DMA is currently scanning. +// +// The three panel-owned PSRAM frame buffers have fixed roles: +// - one front buffer is scanned by DSI; +// - one logical buffer is owned by LVGL; +// - the other physical buffer is the software-rotation target. +// +// After a frame is prepared, it becomes visible only at the next DSI refresh +// boundary. LVGL is released from its flush only after that boundary, so the +// old front buffer cannot be reused while the panel is still reading it. +class P4DsiRotatingPresenter final +{ +public: + bool init(esp_lcd_panel_handle_t panel, + uint32_t physical_hres, + uint32_t physical_vres, + uint8_t bits_per_pixel, + lv_color_format_t color_format, + lv_display_rotation_t rotation); + + lv_display_t* display() const; + std::size_t frame_bytes() const; + +private: + static void flush_callback(lv_display_t* display, + const lv_area_t* area, + uint8_t* pixel_map); + static bool IRAM_ATTR refresh_done_callback( + esp_lcd_panel_handle_t panel, + esp_lcd_dpi_panel_event_data_t* event_data, + void* user_context); + + bool present_full_frame(const lv_area_t* area, uint8_t* pixel_map); + void rotate_with_lvgl(void* output_buffer, const uint8_t* input_buffer) const; + + esp_lcd_panel_handle_t panel_ = nullptr; + lv_display_t* display_ = nullptr; + void* scanout_buffers_[2] = {nullptr, nullptr}; + void* logical_buffer_ = nullptr; + std::atomic front_buffer_{nullptr}; + std::atomic pending_front_buffer_{nullptr}; + std::atomic awaiting_refresh_{false}; + uint32_t physical_hres_ = 0; + uint32_t physical_vres_ = 0; + lv_color_format_t color_format_ = LV_COLOR_FORMAT_UNKNOWN; + lv_display_rotation_t rotation_ = LV_DISPLAY_ROTATION_0; + std::size_t frame_bytes_ = 0; +}; + +} // namespace boards::t_display_p4 diff --git a/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp b/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp index 985f6118..84199730 100644 --- a/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp +++ b/platform/esp/idf_components/t_display_p4/trail_mate_t_display_p4_runtime.cpp @@ -27,6 +27,7 @@ #include "hi8561_driver.h" #include "lvgl.h" #include "platform/esp/idf_common/app_runtime_support.h" +#include "p4_dsi_rotating_presenter.h" #include "rm69a10_driver.h" #include "sdkconfig.h" #include "soc/soc_caps.h" @@ -76,18 +77,41 @@ constexpr uint32_t kKeyboardI2cDelayUs = 5; constexpr uint32_t kKeyboardResetDelayMs = 10; constexpr uint32_t kKeyboardPowerSettleDelayMs = 50; constexpr uint32_t kKeyboardMonitorIntervalMs = 2000; +constexpr uint32_t kKeyboardProbeDiagnosticIntervalMs = 10000; +constexpr uint32_t kKeyboardPollFallbackIntervalMs = 30; +constexpr uint32_t kKeyboardAttachRecoveryCooldownMs = 30000; +constexpr uint32_t kKeyboardAttachRecoveryRailOffMs = 200; +constexpr uint32_t kKeyboardAttachRecoverySettleMs = 300; +constexpr bool kKeyboardAttachRecoveryAutoEnabled = false; constexpr uint32_t kKeyboardMonitorUiIntervalMs = 200; constexpr uint32_t kKeyboardMonitorTaskStackSize = 4096; constexpr UBaseType_t kKeyboardMonitorTaskPriority = 3; constexpr uint32_t kAppLifecycleUiTimerIntervalMs = 10; constexpr std::size_t kAppLifecycleUiEventsPerTick = 4; +constexpr std::size_t kKeyboardEventQueueCapacity = 16; constexpr uint8_t kKeyboardAttachDebounceCount = 2; constexpr uint8_t kKeyboardDetachDebounceCount = 3; +constexpr uint8_t kKeyboardAttachRecoveryProbeCount = 3; constexpr uint8_t kKeyboardI2cScanFirstAddress = 0x03; constexpr uint8_t kKeyboardI2cScanLastAddress = 0x77; constexpr uint8_t kXl9555RegOutputPort0 = 0x02; constexpr uint8_t kXl9555RegConfigPort0 = 0x06; +constexpr uint8_t kXl9555TMixRfEnableMask = 1U << 0; +constexpr uint8_t kXl9555TMixRfSwitch0Mask = 1U << 1; +constexpr uint8_t kXl9555TMixRfSwitch1Mask = 1U << 2; +constexpr uint8_t kXl9555Led1Mask = 1U << 3; +constexpr uint8_t kXl9555Led2Mask = 1U << 4; +constexpr uint8_t kXl9555Led3Mask = 1U << 5; constexpr uint8_t kXl9555Tca8418ResetMask = 1U << 6; +constexpr uint8_t kXl9555KeyboardOutputMask = + static_cast(kXl9555TMixRfEnableMask | kXl9555TMixRfSwitch0Mask | + kXl9555TMixRfSwitch1Mask | kXl9555Led1Mask | + kXl9555Led2Mask | kXl9555Led3Mask | + kXl9555Tca8418ResetMask); +constexpr uint8_t kXl9555KeyboardIdleHighMask = + static_cast(kXl9555TMixRfEnableMask | kXl9555TMixRfSwitch0Mask | + kXl9555Led1Mask | kXl9555Led2Mask | kXl9555Led3Mask | + kXl9555Tca8418ResetMask); constexpr uint8_t kTca8418RegCfg = 0x01; constexpr uint8_t kTca8418RegIntStat = 0x02; constexpr uint8_t kTca8418RegKeyLockEventCount = 0x03; @@ -115,6 +139,12 @@ enum class KeyboardMonitorUiAction : uint8_t Detach = 2, }; +struct KeyboardBufferedEvent +{ + uint32_t key = 0; + bool pressed = false; +}; + constexpr std::array kTca8418LvglKeyMap = { 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, LV_KEY_ESC, LV_KEY_ESC, '1', '2', '3', '4', '5', '6', '7', '8', @@ -142,6 +172,7 @@ esp_lcd_dsi_bus_handle_t s_dsi_bus = nullptr; esp_lcd_panel_io_handle_t s_panel_io = nullptr; esp_lcd_panel_handle_t s_panel = nullptr; lv_display_t* s_display = nullptr; +boards::t_display_p4::P4DsiRotatingPresenter s_rotating_presenter; lv_indev_t* s_touch_indev = nullptr; lv_indev_t* s_keyboard_indev = nullptr; lv_timer_t* s_keyboard_monitor_ui_timer = nullptr; @@ -157,9 +188,16 @@ bool s_keyboard_pressed = false; bool s_keyboard_caps_lock = false; bool s_keyboard_shift_active = false; bool s_keyboard_interrupt_registered = false; +bool s_keyboard_i2c_pins_swapped = false; int s_brightness_percent = 0; uint32_t s_keyboard_last_key = 0; uint32_t s_keyboard_last_alt_press_ms = 0; +TickType_t s_keyboard_last_poll_ticks = 0; +std::array s_keyboard_event_queue{}; +std::size_t s_keyboard_event_queue_head = 0; +std::size_t s_keyboard_event_queue_count = 0; +uint32_t s_keyboard_textarea_suppressed_release_key = 0; +bool s_keyboard_textarea_suppressed_release_pending = false; uint8_t s_keyboard_attach_probe_count = 0; uint8_t s_keyboard_detach_probe_count = 0; std::atomic s_keyboard_monitor_ui_action{ @@ -555,12 +593,30 @@ const boards::t_display_p4::BoardProfile::KeyboardModule& keyboard_module() return boards::t_display_p4::TDisplayP4Board::keyboardModule(); } +int keyboard_sda_pin() +{ + const auto& kb = keyboard_module(); + return s_keyboard_i2c_pins_swapped ? kb.scl : kb.sda; +} + +int keyboard_scl_pin() +{ + const auto& kb = keyboard_module(); + return s_keyboard_i2c_pins_swapped ? kb.sda : kb.scl; +} + struct KeyboardProbeResult { bool supported = false; bool power_ready = false; bool gpio_ready = false; + bool idle_sda = false; + bool idle_scl = false; bool xl9555_ack = false; + bool swapped_probe_attempted = false; + bool swapped_xl9555_ack = false; + bool swapped_tca8418_ack = false; + bool tca8418_reset_attempted = false; bool tca8418_reset_ok = false; bool tca8418_ack = false; bool tca8418_cfg_readable = false; @@ -583,34 +639,36 @@ void keyboard_i2c_delay() void keyboard_sda(bool high) { - gpio_set_level(static_cast(keyboard_module().sda), high ? 1 : 0); + gpio_set_level(static_cast(keyboard_sda_pin()), high ? 1 : 0); } void keyboard_scl(bool high) { - gpio_set_level(static_cast(keyboard_module().scl), high ? 1 : 0); + gpio_set_level(static_cast(keyboard_scl_pin()), high ? 1 : 0); } bool keyboard_read_sda() { - return gpio_get_level(static_cast(keyboard_module().sda)) != 0; + return gpio_get_level(static_cast(keyboard_sda_pin())) != 0; } bool keyboard_read_scl() { - return gpio_get_level(static_cast(keyboard_module().scl)) != 0; + return gpio_get_level(static_cast(keyboard_scl_pin())) != 0; } bool configure_keyboard_i2c_pins() { const auto& kb = keyboard_module(); - if (kb.sda < 0 || kb.scl < 0 || kb.tca8418 == 0) + const int sda_pin = keyboard_sda_pin(); + const int scl_pin = keyboard_scl_pin(); + if (kb.sda < 0 || kb.scl < 0 || sda_pin < 0 || scl_pin < 0 || kb.tca8418 == 0) { return false; } gpio_config_t sda_cfg{}; - sda_cfg.pin_bit_mask = 1ULL << static_cast(kb.sda); + sda_cfg.pin_bit_mask = 1ULL << static_cast(sda_pin); sda_cfg.mode = GPIO_MODE_INPUT_OUTPUT_OD; sda_cfg.pull_up_en = GPIO_PULLUP_ENABLE; sda_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -620,12 +678,15 @@ bool configure_keyboard_i2c_pins() #endif if (gpio_config(&sda_cfg) != ESP_OK) { - ESP_LOGW(kTag, "Keyboard software I2C SDA GPIO config failed"); + ESP_LOGW(kTag, + "Keyboard software I2C SDA GPIO config failed pin=%d swap=%d", + sda_pin, + s_keyboard_i2c_pins_swapped); return false; } gpio_config_t scl_cfg{}; - scl_cfg.pin_bit_mask = 1ULL << static_cast(kb.scl); + scl_cfg.pin_bit_mask = 1ULL << static_cast(scl_pin); scl_cfg.mode = GPIO_MODE_INPUT_OUTPUT_OD; scl_cfg.pull_up_en = GPIO_PULLUP_ENABLE; scl_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -635,7 +696,10 @@ bool configure_keyboard_i2c_pins() #endif if (gpio_config(&scl_cfg) != ESP_OK) { - ESP_LOGW(kTag, "Keyboard software I2C SCL GPIO config failed"); + ESP_LOGW(kTag, + "Keyboard software I2C SCL GPIO config failed pin=%d swap=%d", + scl_pin, + s_keyboard_i2c_pins_swapped); return false; } @@ -687,16 +751,24 @@ void keyboard_recover_i2c_bus(const char* reason) } keyboard_i2c_stop(); - ESP_LOGI(kTag, - "T-Display-P4 keyboard I2C bus ready reason=%s sda=%d scl=%d idle_before=%d/%d idle_after=%d/%d recover_pulses=%d", - reason ? reason : "unknown", - keyboard_module().sda, - keyboard_module().scl, - idle_sda_before, - idle_scl_before, - keyboard_read_sda(), - keyboard_read_scl(), - pulses); + static TickType_t last_log_tick = 0; + const TickType_t now = xTaskGetTickCount(); + if (last_log_tick == 0 || + static_cast(now - last_log_tick) >= pdMS_TO_TICKS(5000)) + { + ESP_LOGI(kTag, + "T-Display-P4 keyboard I2C bus recovery reason=%s sda=%d scl=%d swap=%d idle_before=%d/%d idle_after=%d/%d recover_pulses=%d", + reason ? reason : "unknown", + keyboard_sda_pin(), + keyboard_scl_pin(), + s_keyboard_i2c_pins_swapped, + idle_sda_before, + idle_scl_before, + keyboard_read_sda(), + keyboard_read_scl(), + pulses); + last_log_tick = now; + } } bool keyboard_i2c_write_byte_raw(uint8_t value) @@ -926,14 +998,55 @@ KeyboardProbeResult probe_keyboard_module_bus(bool reset_tca_after_xl = true) keyboard_recover_i2c_bus("probe"); const auto& kb = keyboard_module(); + result.idle_sda = keyboard_read_sda(); + result.idle_scl = keyboard_read_scl(); result.xl9555_ack = kb.xl9555 != 0 && keyboard_probe_device_address(kb.xl9555); if (!result.xl9555_ack) { - return result; + result.tca8418_ack = kb.tca8418 != 0 && keyboard_probe_device_address(kb.tca8418); } - if (reset_tca_after_xl) + if (!result.xl9555_ack && !result.tca8418_ack && !s_keyboard_i2c_pins_swapped) { + result.swapped_probe_attempted = true; + s_keyboard_i2c_pins_swapped = true; + if (configure_keyboard_i2c_pins()) + { + keyboard_recover_i2c_bus("probe_swapped"); + result.idle_sda = keyboard_read_sda(); + result.idle_scl = keyboard_read_scl(); + result.swapped_xl9555_ack = + kb.xl9555 != 0 && keyboard_probe_device_address(kb.xl9555); + result.swapped_tca8418_ack = + kb.tca8418 != 0 && keyboard_probe_device_address(kb.tca8418); + if (result.swapped_xl9555_ack || result.swapped_tca8418_ack) + { + result.xl9555_ack = result.swapped_xl9555_ack; + result.tca8418_ack = result.swapped_tca8418_ack; + ESP_LOGW(kTag, + "T-Display-P4 keyboard I2C responded only with swapped SDA/SCL; using sda=%d scl=%d", + keyboard_sda_pin(), + keyboard_scl_pin()); + } + else + { + s_keyboard_i2c_pins_swapped = false; + (void)configure_keyboard_i2c_pins(); + keyboard_recover_i2c_bus("probe_restore"); + result.idle_sda = keyboard_read_sda(); + result.idle_scl = keyboard_read_scl(); + } + } + else + { + s_keyboard_i2c_pins_swapped = false; + (void)configure_keyboard_i2c_pins(); + } + } + + if (result.xl9555_ack && reset_tca_after_xl) + { + result.tca8418_reset_attempted = true; result.tca8418_reset_ok = reset_tca8418_via_keyboard_expander(); if (!result.tca8418_reset_ok) { @@ -960,13 +1073,20 @@ void log_keyboard_probe_result(const char* reason, if (warning) { ESP_LOGW(kTag, - "T-Display-P4 keyboard probe reason=%s supported=%d power=%d gpio=%d xl9555=0x%02X:%d reset=%d tca8418=0x%02X:%d cfg=%d module_present=%d controller_ready=%d", + "T-Display-P4 keyboard probe reason=%s supported=%d power_request=%d gpio=%d idle_sda/scl=%d/%d i2c_swap=%d swapped_probe=%d swapped_xl9555=%d swapped_tca8418=%d xl9555=0x%02X:ack=%d reset_attempted=%d reset_ok=%d tca8418=0x%02X:ack=%d cfg_read=%d module_present=%d controller_ready=%d", tag_reason, probe.supported, probe.power_ready, probe.gpio_ready, + probe.idle_sda, + probe.idle_scl, + s_keyboard_i2c_pins_swapped, + probe.swapped_probe_attempted, + probe.swapped_xl9555_ack, + probe.swapped_tca8418_ack, static_cast(kb.xl9555), probe.xl9555_ack, + probe.tca8418_reset_attempted, probe.tca8418_reset_ok, static_cast(kb.tca8418), probe.tca8418_ack, @@ -977,13 +1097,20 @@ void log_keyboard_probe_result(const char* reason, } ESP_LOGI(kTag, - "T-Display-P4 keyboard probe reason=%s supported=%d power=%d gpio=%d xl9555=0x%02X:%d reset=%d tca8418=0x%02X:%d cfg=%d module_present=%d controller_ready=%d", + "T-Display-P4 keyboard probe reason=%s supported=%d power_request=%d gpio=%d idle_sda/scl=%d/%d i2c_swap=%d swapped_probe=%d swapped_xl9555=%d swapped_tca8418=%d xl9555=0x%02X:ack=%d reset_attempted=%d reset_ok=%d tca8418=0x%02X:ack=%d cfg_read=%d module_present=%d controller_ready=%d", tag_reason, probe.supported, probe.power_ready, probe.gpio_ready, + probe.idle_sda, + probe.idle_scl, + s_keyboard_i2c_pins_swapped, + probe.swapped_probe_attempted, + probe.swapped_xl9555_ack, + probe.swapped_tca8418_ack, static_cast(kb.xl9555), probe.xl9555_ack, + probe.tca8418_reset_attempted, probe.tca8418_reset_ok, static_cast(kb.tca8418), probe.tca8418_ack, @@ -992,16 +1119,6 @@ void log_keyboard_probe_result(const char* reason, probe.keyboard_controller_ready()); } -bool probe_tca8418_presence() -{ - return probe_keyboard_module_bus(false).keyboard_controller_ready(); -} - -bool probe_keyboard_module_bus_presence() -{ - return probe_keyboard_module_bus().module_present(); -} - void log_keyboard_i2c_scan(const char* reason) { if (!keyboard_module_supported()) @@ -1036,6 +1153,8 @@ void log_keyboard_i2c_scan(const char* reason) bool found_xl9555 = false; bool found_tca8418 = false; const auto& kb = keyboard_module(); + const int sda_pin = keyboard_sda_pin(); + const int scl_pin = keyboard_scl_pin(); for (uint8_t address = kKeyboardI2cScanFirstAddress; address <= kKeyboardI2cScanLastAddress; ++address) @@ -1063,20 +1182,22 @@ void log_keyboard_i2c_scan(const char* reason) if (count == 0) { ESP_LOGW(kTag, - "T-Display-P4 keyboard I2C scan reason=%s sda=%d scl=%d found=none expected_xl9555=0x%02X expected_tca8418=0x%02X", + "T-Display-P4 keyboard I2C scan reason=%s sda=%d scl=%d swap=%d found=none expected_xl9555=0x%02X expected_tca8418=0x%02X", reason ? reason : "unknown", - kb.sda, - kb.scl, + sda_pin, + scl_pin, + s_keyboard_i2c_pins_swapped, static_cast(kb.xl9555), static_cast(kb.tca8418)); return; } ESP_LOGI(kTag, - "T-Display-P4 keyboard I2C scan reason=%s sda=%d scl=%d found_count=%d found=%s expected_xl9555=0x%02X:%d expected_tca8418=0x%02X:%d", + "T-Display-P4 keyboard I2C scan reason=%s sda=%d scl=%d swap=%d found_count=%d found=%s expected_xl9555=0x%02X:%d expected_tca8418=0x%02X:%d", reason ? reason : "unknown", - kb.sda, - kb.scl, + sda_pin, + scl_pin, + s_keyboard_i2c_pins_swapped, count, found, static_cast(kb.xl9555), @@ -1091,110 +1212,23 @@ void log_keyboard_hardware_i2c_scan(const char* reason) { return; } - const auto& kb = keyboard_module(); - if (kb.sda < 0 || kb.scl < 0) - { - return; - } - if (!ensure_keyboard_module_power()) - { - ESP_LOGW(kTag, - "T-Display-P4 keyboard HW I2C scan unavailable reason=%s power_failed", - reason ? reason : "unknown"); - return; - } - - KeyboardI2cGuard lock(500); - if (!lock.locked()) - { - ESP_LOGW(kTag, - "T-Display-P4 keyboard HW I2C scan unavailable reason=%s mutex_timeout", - reason ? reason : "unknown"); - return; - } - - i2c_master_bus_config_t bus_cfg{}; - bus_cfg.i2c_port = -1; - bus_cfg.sda_io_num = static_cast(kb.sda); - bus_cfg.scl_io_num = static_cast(kb.scl); - bus_cfg.clk_source = I2C_CLK_SRC_DEFAULT; - bus_cfg.glitch_ignore_cnt = 7; - bus_cfg.flags.enable_internal_pullup = true; - - i2c_master_bus_handle_t bus = nullptr; - esp_err_t bus_err = i2c_new_master_bus(&bus_cfg, &bus); -#if SOC_LP_I2C_SUPPORTED - if (bus_err == ESP_ERR_NOT_FOUND) - { - bus_cfg.i2c_port = SOC_HP_I2C_NUM; - bus_cfg.lp_source_clk = LP_I2C_SCLK_DEFAULT; - bus_err = i2c_new_master_bus(&bus_cfg, &bus); - } -#endif - if (bus_err != ESP_OK) - { - ESP_LOGW(kTag, - "T-Display-P4 keyboard HW I2C scan unavailable reason=%s sda=%d scl=%d err=%s", - reason ? reason : "unknown", - kb.sda, - kb.scl, - esp_err_to_name(bus_err)); - return; - } - - char found[192] = {}; - size_t used = 0; - int count = 0; - bool found_xl9555 = false; - bool found_tca8418 = false; - for (uint8_t address = kKeyboardI2cScanFirstAddress; - address <= kKeyboardI2cScanLastAddress; - ++address) - { - if (i2c_master_probe(bus, address, 20) != ESP_OK) - { - continue; - } - - const int written = std::snprintf(found + used, - sizeof(found) - used, - "%s0x%02X", - count == 0 ? "" : " ", - static_cast(address)); - if (written > 0) - { - used += std::min(static_cast(written), - used < sizeof(found) ? sizeof(found) - used - 1 : 0); - } - ++count; - found_xl9555 = found_xl9555 || address == kb.xl9555; - found_tca8418 = found_tca8418 || address == kb.tca8418; - } - - (void)i2c_del_master_bus(bus); - if (count == 0) - { - ESP_LOGW(kTag, - "T-Display-P4 keyboard HW I2C scan reason=%s sda=%d scl=%d found=none expected_xl9555=0x%02X expected_tca8418=0x%02X", - reason ? reason : "unknown", - kb.sda, - kb.scl, - static_cast(kb.xl9555), - static_cast(kb.tca8418)); - return; - } - ESP_LOGI(kTag, - "T-Display-P4 keyboard HW I2C scan reason=%s sda=%d scl=%d found_count=%d found=%s expected_xl9555=0x%02X:%d expected_tca8418=0x%02X:%d", - reason ? reason : "unknown", - kb.sda, - kb.scl, - count, - found, - static_cast(kb.xl9555), - found_xl9555, - static_cast(kb.tca8418), - found_tca8418); + "T-Display-P4 keyboard native I2C scan skipped reason=%s: GPIO46/45 use the dedicated software-I2C driver; the managed hardware controllers are already allocated, so only the software-I2C probe is meaningful", + reason ? reason : "unknown"); +} + +bool keyboard_probe_needs_attach_power_recovery(const KeyboardProbeResult& probe) +{ + return probe.supported && + probe.power_ready && + probe.gpio_ready && + probe.idle_sda && + probe.idle_scl && + probe.swapped_probe_attempted && + !probe.xl9555_ack && + !probe.tca8418_ack && + !probe.swapped_xl9555_ack && + !probe.swapped_tca8418_ack; } void IRAM_ATTR keyboard_irq_isr(void* arg) @@ -1302,7 +1336,7 @@ bool set_keyboard_expander_reset_pin(bool high) return keyboard_write_device_register(kb.xl9555, kXl9555RegOutputPort0, output); } -bool reset_tca8418_via_keyboard_expander() +bool configure_keyboard_expander_outputs() { const auto& kb = keyboard_module(); if (kb.xl9555 == 0) @@ -1315,12 +1349,36 @@ bool reset_tca8418_via_keyboard_expander() { return false; } - config &= static_cast(~kXl9555Tca8418ResetMask); + config &= static_cast(~kXl9555KeyboardOutputMask); if (!keyboard_write_device_register(kb.xl9555, kXl9555RegConfigPort0, config)) { return false; } + uint8_t output = 0xFF; + if (!keyboard_read_device_register(kb.xl9555, kXl9555RegOutputPort0, &output)) + { + return false; + } + const uint8_t preserved_output = + static_cast(output & static_cast(~kXl9555KeyboardOutputMask)); + output = static_cast(preserved_output | kXl9555KeyboardIdleHighMask); + return keyboard_write_device_register(kb.xl9555, kXl9555RegOutputPort0, output); +} + +bool reset_tca8418_via_keyboard_expander() +{ + const auto& kb = keyboard_module(); + if (kb.xl9555 == 0) + { + return false; + } + + if (!configure_keyboard_expander_outputs()) + { + return false; + } + if (!set_keyboard_expander_reset_pin(true)) { return false; @@ -1408,18 +1466,24 @@ bool configure_tca8418_keypad() return false; } - if (!configure_keyboard_interrupt_pin()) + const bool interrupt_ready = configure_keyboard_interrupt_pin(); + if (!interrupt_ready) { - return false; + ESP_LOGW(kTag, + "T-Display-P4 keyboard interrupt unavailable; using %lums poll fallback", + static_cast(kKeyboardPollFallbackIntervalMs)); } - s_keyboard_irq_pending = keyboard_irq_line_active(); + s_keyboard_irq_pending = interrupt_ready && keyboard_irq_line_active(); + s_keyboard_last_poll_ticks = 0; ESP_LOGI(kTag, - "T-Display-P4 keyboard TCA8418 configured matrix=%dx%d irq_pin=%d pending=%d", + "T-Display-P4 keyboard TCA8418 configured matrix=%dx%d irq_pin=%d irq_ready=%d pending=%d poll_fallback_ms=%lu", kb.columns, kb.rows, kb.interrupt, - s_keyboard_irq_pending); + interrupt_ready, + s_keyboard_irq_pending, + static_cast(kKeyboardPollFallbackIntervalMs)); return true; } @@ -1433,6 +1497,31 @@ bool is_keyboard_modifier(uint32_t key) key == kTca8418KeyShift; } +uint8_t next_keyboard_backlight_level(uint8_t current) +{ + constexpr uint8_t max_level = DEVICE_MAX_BRIGHTNESS_LEVEL; + const uint8_t low = max_level >= 4 ? static_cast(max_level / 2U) : 1U; + if (current == 0) + { + return low; + } + if (current < max_level) + { + return max_level; + } + return 0; +} + +void cycle_keyboard_backlight_from_key() +{ + auto& board = boards::t_display_p4::TDisplayP4Board::instance(); + const uint8_t next = next_keyboard_backlight_level(board.keyboardGetBrightness()); + board.keyboardSetBrightness(next); + ESP_LOGI(kTag, + "T-Display-P4 keyboard backlight key cycle level=%u", + static_cast(next)); +} + uint32_t resolve_keyboard_key(uint8_t key_num, bool pressed) { if (key_num == 0 || key_num > kTca8418LvglKeyMap.size()) @@ -1459,6 +1548,15 @@ uint32_t resolve_keyboard_key(uint8_t key_num, bool pressed) return 0; } + if (key == kTca8418KeyWin) + { + if (pressed) + { + cycle_keyboard_backlight_from_key(); + } + return 0; + } + if (key == kTca8418KeyAlt) { if (pressed) @@ -1488,39 +1586,175 @@ uint32_t resolve_keyboard_key(uint8_t key_num, bool pressed) return key; } -bool poll_keyboard_event(uint32_t* out_key, bool* out_pressed) +void clear_keyboard_event_queue() { - if (!out_key || !out_pressed) - { - return false; - } - if (!s_keyboard_irq_pending) + s_keyboard_event_queue_head = 0; + s_keyboard_event_queue_count = 0; +} + +bool enqueue_keyboard_event(uint32_t key, bool pressed) +{ + if (key == 0) { return false; } - KeyboardI2cGuard lock(5); - if (!lock.locked()) + if (s_keyboard_event_queue_count >= s_keyboard_event_queue.size()) + { + s_keyboard_event_queue_head = + (s_keyboard_event_queue_head + 1U) % s_keyboard_event_queue.size(); + --s_keyboard_event_queue_count; + } + + const std::size_t write_index = + (s_keyboard_event_queue_head + s_keyboard_event_queue_count) % + s_keyboard_event_queue.size(); + s_keyboard_event_queue[write_index] = KeyboardBufferedEvent{key, pressed}; + ++s_keyboard_event_queue_count; + return true; +} + +bool dequeue_keyboard_event(uint32_t* out_key, bool* out_pressed) +{ + if (!out_key || !out_pressed || s_keyboard_event_queue_count == 0) { return false; } - s_keyboard_irq_pending = false; + const KeyboardBufferedEvent event = s_keyboard_event_queue[s_keyboard_event_queue_head]; + s_keyboard_event_queue_head = + (s_keyboard_event_queue_head + 1U) % s_keyboard_event_queue.size(); + --s_keyboard_event_queue_count; + *out_key = event.key; + *out_pressed = event.pressed; + return true; +} + +bool keyboard_key_is_textarea_fallback_candidate(uint32_t key) +{ + return (key >= 0x20U && key <= 0x7EU) || + key == LV_KEY_BACKSPACE || + key == LV_KEY_ENTER || + key == LV_KEY_LEFT || + key == LV_KEY_RIGHT || + key == LV_KEY_UP || + key == LV_KEY_DOWN || + key == LV_KEY_HOME || + key == LV_KEY_END || + key == LV_KEY_ESC; +} + +lv_obj_t* find_focused_keyboard_textarea_in(lv_obj_t* obj) +{ + if (obj == nullptr || !lv_obj_is_valid(obj)) + { + return nullptr; + } + + if (lv_obj_check_type(obj, &lv_textarea_class) && + lv_obj_has_state(obj, LV_STATE_FOCUSED)) + { + return obj; + } + + const uint32_t child_count = lv_obj_get_child_count(obj); + for (uint32_t index = 0; index < child_count; ++index) + { + if (lv_obj_t* textarea = find_focused_keyboard_textarea_in(lv_obj_get_child(obj, index))) + { + return textarea; + } + } + return nullptr; +} + +lv_obj_t* find_focused_keyboard_textarea() +{ + lv_group_t* const group = lv_group_get_default(); + lv_obj_t* const focused = group != nullptr ? lv_group_get_focused(group) : nullptr; + if (focused != nullptr && lv_obj_is_valid(focused)) + { + if (lv_obj_check_type(focused, &lv_textarea_class)) + { + return focused; + } + if (lv_obj_t* textarea = find_focused_keyboard_textarea_in(focused)) + { + return textarea; + } + } + + return find_focused_keyboard_textarea_in(lv_screen_active()); +} + +bool dispatch_keyboard_event_to_focused_textarea(uint32_t key, bool pressed) +{ + if (!pressed) + { + if (s_keyboard_textarea_suppressed_release_pending && + s_keyboard_textarea_suppressed_release_key == key) + { + s_keyboard_textarea_suppressed_release_pending = false; + s_keyboard_textarea_suppressed_release_key = 0; + return true; + } + return false; + } + + if (!keyboard_key_is_textarea_fallback_candidate(key)) + { + return false; + } + + lv_obj_t* const textarea = find_focused_keyboard_textarea(); + if (textarea == nullptr) + { + return false; + } + + lv_result_t result = LV_RESULT_OK; + if (key == LV_KEY_ESC) + { + result = lv_obj_send_event(textarea, LV_EVENT_CANCEL, nullptr); + } + else + { + uint32_t key_param = key; + result = lv_obj_send_event(textarea, LV_EVENT_KEY, &key_param); + } + + if (result != LV_RESULT_OK) + { + return false; + } + + s_keyboard_textarea_suppressed_release_key = key; + s_keyboard_textarea_suppressed_release_pending = true; + return true; +} + +void sync_keyboard_indev_group() +{ + if (s_keyboard_indev == nullptr) + { + return; + } + + lv_group_t* const default_group = lv_group_get_default(); + if (default_group != nullptr && lv_indev_get_group(s_keyboard_indev) != default_group) + { + lv_indev_set_group(s_keyboard_indev, default_group); + } +} + +bool drain_keyboard_fifo_locked() +{ uint8_t irq_status = 0; if (!keyboard_read_register(kTca8418RegIntStat, &irq_status)) { s_keyboard_irq_pending = keyboard_irq_line_active(); return false; } - if ((irq_status & kTca8418IntKeyEvents) == 0) - { - if (irq_status != 0) - { - (void)keyboard_write_register(kTca8418RegIntStat, irq_status); - } - s_keyboard_irq_pending = keyboard_irq_line_active(); - return false; - } uint8_t count_reg = 0; if (!keyboard_read_register(kTca8418RegKeyLockEventCount, &count_reg)) @@ -1532,7 +1766,10 @@ bool poll_keyboard_event(uint32_t* out_key, bool* out_pressed) const uint8_t event_count = std::min(count_reg & 0x0F, kTca8418MaxKeyEvents); if (event_count == 0) { - (void)keyboard_write_register(kTca8418RegIntStat, kTca8418IntKeyEvents); + if (irq_status != 0) + { + (void)keyboard_write_register(kTca8418RegIntStat, irq_status); + } s_keyboard_irq_pending = keyboard_irq_line_active(); return false; } @@ -1544,7 +1781,7 @@ bool poll_keyboard_event(uint32_t* out_key, bool* out_pressed) return false; } - bool found = false; + bool queued = false; for (uint8_t index = 0; index < event_count; ++index) { const bool pressed = (events[index] & 0x80U) != 0; @@ -1555,19 +1792,46 @@ bool poll_keyboard_event(uint32_t* out_key, bool* out_pressed) } const uint32_t key = resolve_keyboard_key(key_num, pressed); - if (key == 0) - { - continue; - } - - *out_key = key; - *out_pressed = pressed; - found = true; + queued = enqueue_keyboard_event(key, pressed) || queued; } - (void)keyboard_write_register(kTca8418RegIntStat, kTca8418IntKeyEvents); + (void)keyboard_write_register(kTca8418RegIntStat, + static_cast(irq_status | kTca8418IntKeyEvents)); s_keyboard_irq_pending = keyboard_irq_line_active(); - return found; + return queued; +} + +bool poll_keyboard_event(uint32_t* out_key, bool* out_pressed) +{ + if (!out_key || !out_pressed) + { + return false; + } + if (dequeue_keyboard_event(out_key, out_pressed)) + { + return true; + } + if (!s_keyboard_irq_pending) + { + const TickType_t now = xTaskGetTickCount(); + const TickType_t interval_ticks = + std::max(1, pdMS_TO_TICKS(kKeyboardPollFallbackIntervalMs)); + if (s_keyboard_last_poll_ticks != 0 && + static_cast(now - s_keyboard_last_poll_ticks) < interval_ticks) + { + return false; + } + s_keyboard_last_poll_ticks = now; + } + + KeyboardI2cGuard lock(5); + if (!lock.locked()) + { + return false; + } + s_keyboard_irq_pending = false; + (void)drain_keyboard_fifo_locked(); + return dequeue_keyboard_event(out_key, out_pressed); } bool init_keyboard_backend(bool log_missing = true) @@ -1639,6 +1903,7 @@ bool init_keyboard_backend(bool log_missing = true) void keyboard_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { (void)indev; + sync_keyboard_indev_group(); if (!s_keyboard_ready.load(std::memory_order_acquire)) { data->state = LV_INDEV_STATE_RELEASED; @@ -1649,6 +1914,18 @@ void keyboard_read_cb(lv_indev_t* indev, lv_indev_data_t* data) bool pressed = false; if (poll_keyboard_event(&key, &pressed)) { + if (dispatch_keyboard_event_to_focused_textarea(key, pressed)) + { + if (pressed) + { + trail_mate_idf_note_user_activity(); + } + s_keyboard_pressed = false; + data->state = LV_INDEV_STATE_RELEASED; + data->key = 0; + return; + } + if (key <= 0x7FU && ui_get_active_app() == nullptr) { const char key_char = static_cast(key); @@ -1758,7 +2035,12 @@ bool register_keyboard_indev() lv_indev_set_type(s_keyboard_indev, LV_INDEV_TYPE_KEYPAD); lv_indev_set_read_cb(s_keyboard_indev, keyboard_read_cb); lv_indev_set_display(s_keyboard_indev, s_display); - ESP_LOGI(kTag, "LVGL keyboard input registered"); + lv_group_t* const default_group = lv_group_get_default(); + if (default_group != nullptr) + { + lv_indev_set_group(s_keyboard_indev, default_group); + } + ESP_LOGI(kTag, "LVGL keyboard input registered group=%p", default_group); return true; } @@ -1779,6 +2061,10 @@ void clear_keyboard_runtime_state() s_keyboard_caps_lock = false; s_keyboard_shift_active = false; s_keyboard_last_key = 0; + s_keyboard_last_poll_ticks = 0; + s_keyboard_textarea_suppressed_release_key = 0; + s_keyboard_textarea_suppressed_release_pending = false; + clear_keyboard_event_queue(); boards::t_display_p4::TDisplayP4Board::instance().setKeyboardReady(false); } @@ -1905,6 +2191,10 @@ bool start_app_lifecycle_ui_timer() void keyboard_module_monitor_task(void* arg) { (void)arg; + TickType_t last_probe_diagnostic_tick = 0; + TickType_t last_attach_recovery_tick = 0; + uint8_t attach_recovery_probe_count = 0; + uint32_t attach_recovery_attempts = 0; ESP_LOGI(kTag, "T-Display-P4 keyboard monitor task started interval=%lums attach=%u detach=%u", static_cast(kKeyboardMonitorIntervalMs), @@ -1920,8 +2210,71 @@ void keyboard_module_monitor_task(void* arg) } const bool ready = s_keyboard_ready.load(std::memory_order_acquire); - const bool present = ready ? probe_tca8418_presence() - : probe_keyboard_module_bus_presence(); + // Keep the full probe result here. The short recovery line only says + // that SDA/SCL are idle; it cannot distinguish a missing XL9555 from + // an unresponsive TCA8418. Preserve the old readiness semantics while + // emitting a bounded diagnostic for an absent or degraded module. + KeyboardProbeResult probe = probe_keyboard_module_bus(!ready); + bool present = ready ? probe.keyboard_controller_ready() : probe.module_present(); + const TickType_t now = xTaskGetTickCount(); + if ((!ready || !present) && + (last_probe_diagnostic_tick == 0 || + static_cast(now - last_probe_diagnostic_tick) >= + pdMS_TO_TICKS(kKeyboardProbeDiagnosticIntervalMs))) + { + log_keyboard_probe_result("monitor", probe, true); + last_probe_diagnostic_tick = now; + } + + if (kKeyboardAttachRecoveryAutoEnabled && + !ready && + !present && + keyboard_probe_needs_attach_power_recovery(probe)) + { + attach_recovery_probe_count = + std::min(kKeyboardAttachRecoveryProbeCount, + static_cast(attach_recovery_probe_count + 1)); + const bool cooldown_ready = + last_attach_recovery_tick == 0 || + static_cast(now - last_attach_recovery_tick) >= + pdMS_TO_TICKS(kKeyboardAttachRecoveryCooldownMs); + if (attach_recovery_probe_count >= kKeyboardAttachRecoveryProbeCount && + cooldown_ready) + { + ++attach_recovery_attempts; + last_attach_recovery_tick = now; + attach_recovery_probe_count = 0; + ESP_LOGW(kTag, + "T-Display-P4 keyboard attach recovery attempt=%lu reason=no_i2c_ack off_ms=%lu settle_ms=%lu cooldown_ms=%lu", + static_cast(attach_recovery_attempts), + static_cast(kKeyboardAttachRecoveryRailOffMs), + static_cast(kKeyboardAttachRecoverySettleMs), + static_cast(kKeyboardAttachRecoveryCooldownMs)); + const bool recovered = + boards::t_display_p4::TDisplayP4Board::instance() + .recoverExternal3v3ForKeyboardAttach(kKeyboardAttachRecoveryRailOffMs, + kKeyboardAttachRecoverySettleMs); + if (recovered) + { + probe = probe_keyboard_module_bus(true); + present = probe.module_present(); + log_keyboard_probe_result("monitor_power_recovery", + probe, + !probe.keyboard_controller_ready()); + last_probe_diagnostic_tick = xTaskGetTickCount(); + } + else + { + ESP_LOGW(kTag, + "T-Display-P4 keyboard attach recovery failed before reprobe"); + } + } + } + else if (present) + { + attach_recovery_probe_count = 0; + } + if (present) { s_keyboard_attach_probe_count = @@ -2047,7 +2400,10 @@ bool create_panel() dpi_cfg.dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT; dpi_cfg.dpi_clock_freq_mhz = static_cast(panel.dpi_clock_mhz); dpi_cfg.pixel_format = panel_pixel_format(); - dpi_cfg.num_fbs = 1; + // Rotation requires a display-owned front/back pair plus one logical LVGL + // surface. The presenter assigns those three existing full-frame surfaces + // explicit ownership and never writes the DSI front frame in place. + dpi_cfg.num_fbs = 3; dpi_cfg.video_timing.h_size = static_cast(panel.width); dpi_cfg.video_timing.v_size = static_cast(panel.height); dpi_cfg.video_timing.hsync_pulse_width = static_cast(panel.hsync); @@ -2140,48 +2496,22 @@ bool create_display() } const auto& panel = active_panel(); - const uint32_t full_frame_buffer_pixels = - static_cast(panel.width) * static_cast(panel.height); - lvgl_port_display_cfg_t disp_cfg{}; - disp_cfg.io_handle = s_panel_io; - disp_cfg.panel_handle = s_panel; - disp_cfg.control_handle = nullptr; - disp_cfg.buffer_size = full_frame_buffer_pixels; - disp_cfg.double_buffer = false; - disp_cfg.trans_size = 0; - disp_cfg.hres = static_cast(panel.width); - disp_cfg.vres = static_cast(panel.height); - disp_cfg.monochrome = false; - disp_cfg.rotation.swap_xy = false; - disp_cfg.rotation.mirror_x = false; - disp_cfg.rotation.mirror_y = false; -#if LVGL_VERSION_MAJOR >= 9 - disp_cfg.color_format = lvgl_color_format(); -#endif -#if CONFIG_TRAIL_MATE_T_DISPLAY_P4_PIXEL_FORMAT_RGB565 - disp_cfg.flags.buff_dma = true; -#else - disp_cfg.flags.buff_dma = false; -#endif - disp_cfg.flags.buff_spiram = true; - disp_cfg.flags.sw_rotate = true; -#if LVGL_VERSION_MAJOR >= 9 - disp_cfg.flags.swap_bytes = false; -#endif - disp_cfg.flags.full_refresh = 0; - disp_cfg.flags.direct_mode = 0; - - lvgl_port_display_dsi_cfg_t dsi_cfg{}; - dsi_cfg.flags.avoid_tearing = false; - - s_display = lvgl_port_add_disp_dsi(&disp_cfg, &dsi_cfg); - if (s_display == nullptr) + if (!s_rotating_presenter.init(s_panel, + static_cast(panel.width), + static_cast(panel.height), + static_cast(bits_per_pixel()), + lvgl_color_format(), + LV_DISPLAY_ROTATION_90)) { - ESP_LOGE(kTag, "Failed to add DSI display to LVGL"); + ESP_LOGE(kTag, "Failed to create P4 DSI rotating presenter"); + return false; + } + s_display = s_rotating_presenter.display(); + if (s_display == nullptr) + { + ESP_LOGE(kTag, "P4 DSI rotating presenter returned no LVGL display"); return false; } - - lv_display_set_rotation(s_display, LV_DISPLAY_ROTATION_90); return true; } @@ -2342,7 +2672,7 @@ extern "C" bool trail_mate_t_display_p4_display_runtime_init(void) s_ready = true; ESP_LOGI(kTag, - "Display runtime ready buffer_pixels=%lu buffer_mode=single-full-frame brightness=%d", + "Display runtime ready buffer_pixels=%lu buffer_mode=three-surface-vsync brightness=%d", static_cast(static_cast(active_panel().width) * static_cast(active_panel().height)), s_brightness_percent);