From 0c0f82ff9f7d6b3065029436b6a61491d695f268 Mon Sep 17 00:00:00 2001 From: ZhaoXingPeng <848238014@qq.com> Date: Tue, 18 Aug 2026 11:21:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor(runtime):?= =?UTF-8?q?=20=E5=BB=BA=E7=AB=8B=E5=8F=AF=E4=B8=BB=E6=9C=BA=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=9A=84=E4=BA=A4=E4=BA=92=E6=9E=B6=E6=9E=84=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../voicelife_application/CMakeLists.txt | 4 ++ .../application/interaction_orchestrator.h | 60 +++++++++++++++++++ .../src/interaction_orchestrator.cc | 24 ++++++++ components/voicelife_runtime/CMakeLists.txt | 3 +- components/voicelife_runtime/src/runtime.cc | 5 ++ .../voicelife_runtime_esp/CMakeLists.txt | 6 ++ .../runtime_esp/esp_interaction_task_host.h | 29 +++++++++ .../src/esp_interaction_task_host.cc | 16 +++++ scripts/check_architecture.cmake | 8 ++- tests/host/CMakeLists.txt | 6 ++ tests/host/interaction_orchestrator_test.cc | 51 ++++++++++++++++ 11 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 components/voicelife_application/CMakeLists.txt create mode 100644 components/voicelife_application/include/voicelife/application/interaction_orchestrator.h create mode 100644 components/voicelife_application/src/interaction_orchestrator.cc create mode 100644 components/voicelife_runtime_esp/CMakeLists.txt create mode 100644 components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h create mode 100644 components/voicelife_runtime_esp/src/esp_interaction_task_host.cc create mode 100644 tests/host/interaction_orchestrator_test.cc diff --git a/components/voicelife_application/CMakeLists.txt b/components/voicelife_application/CMakeLists.txt new file mode 100644 index 00000000..e2f38404 --- /dev/null +++ b/components/voicelife_application/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "src/interaction_orchestrator.cc" + INCLUDE_DIRS "include" +) diff --git a/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h b/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h new file mode 100644 index 00000000..105a1814 --- /dev/null +++ b/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +namespace voicelife::application { + +/** @brief 交互应用层接收的跨域事件分类,不携带平台 SDK 类型。 */ +enum class InteractionEventKind : uint8_t { + kBootstrapRequested, + kBoardInputArrived, + kVoiceLifecycleChanged, + kConnectivityChanged, +}; + +/** @brief 一次交互编排请求的稳定输入模型。 */ +struct InteractionEvent { + InteractionEventKind kind = InteractionEventKind::kBootstrapRequested; +}; + +/** @brief 交互编排器发给 Runtime Adapter 的稳定动作分类。 */ +enum class InteractionActionKind : uint8_t { + kInitializeInteraction, + kDispatchBoardInput, + kDispatchVoiceLifecycle, + kRefreshConnectivity, +}; + +/** @brief 一次交互编排请求产生的动作。 */ +struct InteractionAction { + InteractionActionKind kind = InteractionActionKind::kInitializeInteraction; + + friend constexpr bool operator==(InteractionAction lhs, InteractionAction rhs) { return lhs.kind == rhs.kind; } +}; + +/** @brief Runtime Adapter 实现的动作接收端口。 */ +class InteractionActionSink { + public: + /** @brief 虚析构函数。 */ + virtual ~InteractionActionSink() = default; + /** @brief 接收一个应用层编排动作。 @param action 要执行的语义动作。 */ + virtual void Submit(InteractionAction action) = 0; +}; + +/** + * @brief 平台无关的交互应用服务。 + * + * 该骨架只定义跨域事件与动作的编排边界。既有 Runtime 事件循环仍保持原状; + * 后续迁移必须先用行为轨迹证明等价,才能将现有事件接入此服务。 + */ +class InteractionOrchestrator { + public: + /** + * @brief 将一个跨域事件映射为一个确定的 Runtime Adapter 动作。 + * @param event 要编排的跨域交互事件。 + * @param actions 用于记录或执行动作的 Runtime Adapter 端口。 + */ + void Handle(InteractionEvent event, InteractionActionSink& actions) const; +}; + +} // namespace voicelife::application diff --git a/components/voicelife_application/src/interaction_orchestrator.cc b/components/voicelife_application/src/interaction_orchestrator.cc new file mode 100644 index 00000000..006619c8 --- /dev/null +++ b/components/voicelife_application/src/interaction_orchestrator.cc @@ -0,0 +1,24 @@ +#include "voicelife/application/interaction_orchestrator.h" + +namespace voicelife::application { + +void InteractionOrchestrator::Handle(InteractionEvent event, InteractionActionSink& actions) const { + InteractionActionKind action = InteractionActionKind::kInitializeInteraction; + switch (event.kind) { + case InteractionEventKind::kBootstrapRequested: + action = InteractionActionKind::kInitializeInteraction; + break; + case InteractionEventKind::kBoardInputArrived: + action = InteractionActionKind::kDispatchBoardInput; + break; + case InteractionEventKind::kVoiceLifecycleChanged: + action = InteractionActionKind::kDispatchVoiceLifecycle; + break; + case InteractionEventKind::kConnectivityChanged: + action = InteractionActionKind::kRefreshConnectivity; + break; + } + actions.Submit({.kind = action}); +} + +} // namespace voicelife::application diff --git a/components/voicelife_runtime/CMakeLists.txt b/components/voicelife_runtime/CMakeLists.txt index efae44c5..8deb09f7 100644 --- a/components/voicelife_runtime/CMakeLists.txt +++ b/components/voicelife_runtime/CMakeLists.txt @@ -5,7 +5,8 @@ idf_component_register( "src/im_binding_mcp_tools.cc" "src/im_binding_presentation.cc" INCLUDE_DIRS "include" "src" REQUIRES voicelife_contracts - PRIV_REQUIRES voicelife_mcp voicelife_voice voicelife_linx voicelife_linx_esp voicelife_audio_esp + PRIV_REQUIRES voicelife_application voicelife_runtime_esp voicelife_mcp voicelife_voice voicelife_linx + voicelife_linx_esp voicelife_audio_esp voicelife_display_esp voicelife_schedule voicelife_im voicelife_storage_fatfs voicelife_storage_sqlite nvs_flash nvs_sec_provider esp_timer esp_http_client esp-tls esp_wifi esp_netif lwip esp_event esp_http_server spi_flash esp_partition esp_psram esp_app_format esp_driver_gpio diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc index c5457067..771efe00 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -49,7 +49,9 @@ #include "linx_mcp_bridge.h" #include "linx_ota_bootstrap.h" #include "mcp_worker_policy.h" +#include "voicelife/application/interaction_orchestrator.h" #include "voicelife/mcp/schedule_mcp_tools.h" +#include "voicelife/runtime_esp/esp_interaction_task_host.h" #include "voicelife/voice/display_snapshot.h" #include "voicelife/voice/voice_interaction_controller.h" #include "voicelife/voice/voice_ports.h" @@ -1962,6 +1964,9 @@ class Runtime final { ScaffoldAudioInput audio_input_; ScaffoldAudioOutput audio_output_; #endif + // 仅完成依赖装配,现有事件循环尚未迁移到该路径。 + application::InteractionOrchestrator interaction_orchestrator_; + runtime_esp::EspInteractionTaskHost interaction_task_host_{interaction_orchestrator_}; voice::VoiceInteractionController interaction_; std::unique_ptr provider_; std::unique_ptr session_; diff --git a/components/voicelife_runtime_esp/CMakeLists.txt b/components/voicelife_runtime_esp/CMakeLists.txt new file mode 100644 index 00000000..b15413f2 --- /dev/null +++ b/components/voicelife_runtime_esp/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/esp_interaction_task_host.cc" + INCLUDE_DIRS "include" + REQUIRES voicelife_application + PRIV_REQUIRES voicelife_mcp freertos +) diff --git a/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h b/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h new file mode 100644 index 00000000..55ea67b7 --- /dev/null +++ b/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h @@ -0,0 +1,29 @@ +#pragma once + +#include "voicelife/application/interaction_orchestrator.h" + +namespace voicelife::runtime_esp { + +/** + * @brief ESP 侧交互任务的窄适配器。 + * + * 本骨架只建立 Runtime Adapter 到应用服务的调用路径,未创建 FreeRTOS task, + * 也未接管既有 Runtime 事件循环。后续迁移只能从该适配器进入。 + */ +class EspInteractionTaskHost { + public: + /** @brief 创建使用指定应用服务的 ESP 交互任务宿主。 @param orchestrator 平台无关的交互编排器。 */ + explicit EspInteractionTaskHost(const application::InteractionOrchestrator& orchestrator); + + /** + * @brief 将 ESP 侧已归一化的事件交给平台无关的编排器。 + * @param event 已归一化的交互事件。 + * @param actions 用于接收编排动作的 Runtime Adapter 端口。 + */ + void Submit(application::InteractionEvent event, application::InteractionActionSink& actions) const; + + private: + const application::InteractionOrchestrator& orchestrator_; +}; + +} // namespace voicelife::runtime_esp diff --git a/components/voicelife_runtime_esp/src/esp_interaction_task_host.cc b/components/voicelife_runtime_esp/src/esp_interaction_task_host.cc new file mode 100644 index 00000000..4b366d00 --- /dev/null +++ b/components/voicelife_runtime_esp/src/esp_interaction_task_host.cc @@ -0,0 +1,16 @@ +#include "voicelife/runtime_esp/esp_interaction_task_host.h" + +#include "freertos/FreeRTOS.h" + +namespace voicelife::runtime_esp { + +EspInteractionTaskHost::EspInteractionTaskHost(const application::InteractionOrchestrator& orchestrator) + : orchestrator_(orchestrator) {} + +void EspInteractionTaskHost::Submit(application::InteractionEvent event, + application::InteractionActionSink& actions) const { + static_assert(configMAX_PRIORITIES > 0, "FreeRTOS task priorities must be configured"); + orchestrator_.Handle(event, actions); +} + +} // namespace voicelife::runtime_esp diff --git a/scripts/check_architecture.cmake b/scripts/check_architecture.cmake index 0944d8f8..18e370f3 100644 --- a/scripts/check_architecture.cmake +++ b/scripts/check_architecture.cmake @@ -5,10 +5,12 @@ if(NOT DEFINED VOICELIFE_ROOT) endif() set(known_components + voicelife_application voicelife_contracts voicelife_im voicelife_mcp voicelife_runtime + voicelife_runtime_esp voicelife_schedule voicelife_storage_fatfs voicelife_storage_sqlite @@ -91,6 +93,8 @@ endforeach() assert_dependencies(voicelife_contracts PUBLIC) assert_dependencies(voicelife_contracts PRIVATE yyjson) +assert_dependencies(voicelife_application PUBLIC) +assert_dependencies(voicelife_application PRIVATE) assert_dependencies(voicelife_im PUBLIC voicelife_contracts) assert_dependencies(voicelife_im PRIVATE esp_http_client mbedtls) assert_dependencies(voicelife_schedule PUBLIC voicelife_contracts) @@ -120,6 +124,8 @@ assert_dependencies(voicelife_audio_esp PRIVATE esp_driver_i2c esp_driver_i2s es assert_dependencies(voicelife_board_esp PUBLIC voicelife_contracts) assert_dependencies(voicelife_board_esp PRIVATE esp_hw_support esp_partition esp_psram esp_system spi_flash) assert_dependencies(voicelife_runtime PUBLIC voicelife_contracts) -assert_dependencies(voicelife_runtime PRIVATE esp-tls esp_app_format esp_driver_gpio esp_driver_usb_serial_jtag led_strip esp_event esp_http_client esp_http_server esp_netif lwip esp_partition esp_psram esp_timer esp_wifi nvs_flash nvs_sec_provider spi_flash voicelife_im voicelife_linx voicelife_linx_esp voicelife_mcp voicelife_voice voicelife_audio_esp voicelife_display_esp voicelife_schedule voicelife_storage_fatfs voicelife_storage_sqlite) +assert_dependencies(voicelife_runtime_esp PUBLIC voicelife_application) +assert_dependencies(voicelife_runtime_esp PRIVATE freertos voicelife_mcp) +assert_dependencies(voicelife_runtime PRIVATE esp-tls esp_app_format esp_driver_gpio esp_driver_usb_serial_jtag led_strip esp_event esp_http_client esp_http_server esp_netif lwip esp_partition esp_psram esp_timer esp_wifi nvs_flash nvs_sec_provider spi_flash voicelife_application voicelife_im voicelife_linx voicelife_linx_esp voicelife_mcp voicelife_runtime_esp voicelife_voice voicelife_audio_esp voicelife_display_esp voicelife_schedule voicelife_storage_fatfs voicelife_storage_sqlite) message(STATUS "PASS component names, include paths, and dependency graph") diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 79f83f2b..0cda0a6c 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -49,6 +49,8 @@ add_voicelife_library(contracts voicelife_contracts "${ROOT_DIR}/components/voicelife_contracts/src/im/notification_submission.cc" "${ROOT_DIR}/components/voicelife_contracts/src/im/pairing_session.cc") target_link_libraries(contracts PRIVATE yyjson) +add_voicelife_library(application voicelife_application + "${ROOT_DIR}/components/voicelife_application/src/interaction_orchestrator.cc") add_voicelife_library(im voicelife_im "${ROOT_DIR}/components/voicelife_im/src/im_reporting_channel.cc" "${ROOT_DIR}/components/voicelife_im/src/im_action_channel.cc" @@ -325,6 +327,10 @@ add_voicelife_test(voice_interaction_controller_test "unit;voice;interaction;con voice_interaction_controller_test.cc) target_link_libraries(voice_interaction_controller_test PRIVATE voice) +add_voicelife_test(interaction_orchestrator_test "unit;application;interaction;contract" + interaction_orchestrator_test.cc) +target_link_libraries(interaction_orchestrator_test PRIVATE application) + add_voicelife_test(linx_provider_contract_test "unit;voice;linx;contract" linx_provider_contract_test.cc) target_link_libraries(linx_provider_contract_test PRIVATE linx) diff --git a/tests/host/interaction_orchestrator_test.cc b/tests/host/interaction_orchestrator_test.cc new file mode 100644 index 00000000..d90a20d2 --- /dev/null +++ b/tests/host/interaction_orchestrator_test.cc @@ -0,0 +1,51 @@ +#include "voicelife/application/interaction_orchestrator.h" + +#include + +#include "support/test_support.h" + +namespace { + +using voicelife::application::InteractionAction; +using voicelife::application::InteractionActionKind; +using voicelife::application::InteractionActionSink; +using voicelife::application::InteractionEvent; +using voicelife::application::InteractionEventKind; +using voicelife::application::InteractionOrchestrator; +using voicelife::test::Check; + +class TraceSink final : public InteractionActionSink { + public: + void Submit(InteractionAction action) override { trace.push_back(action); } + + std::vector trace; +}; + +} // namespace + +int main() { + const InteractionOrchestrator orchestrator; + const std::vector events = { + {.kind = InteractionEventKind::kBootstrapRequested}, + {.kind = InteractionEventKind::kBoardInputArrived}, + {.kind = InteractionEventKind::kVoiceLifecycleChanged}, + {.kind = InteractionEventKind::kConnectivityChanged}, + }; + const std::vector expected_trace = { + {.kind = InteractionActionKind::kInitializeInteraction}, + {.kind = InteractionActionKind::kDispatchBoardInput}, + {.kind = InteractionActionKind::kDispatchVoiceLifecycle}, + {.kind = InteractionActionKind::kRefreshConnectivity}, + }; + + TraceSink first_trace; + TraceSink second_trace; + for (const InteractionEvent event : events) { + orchestrator.Handle(event, first_trace); + orchestrator.Handle(event, second_trace); + } + + Check(first_trace.trace == expected_trace, "编排器必须为固定事件序列生成预期动作轨迹"); + Check(second_trace.trace == first_trace.trace, "相同事件序列必须生成相同动作轨迹"); + return 0; +} From 01167fe4b3e573d0b56941c512400dcd8fb27d07 Mon Sep 17 00:00:00 2001 From: ZhaoXingPeng <848238014@qq.com> Date: Tue, 18 Aug 2026 12:37:52 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(runtime):=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=20ESP=20=E4=BA=8B=E4=BB=B6=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E4=B8=8E=E8=A1=8C=E4=B8=BA=E8=BD=A8=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../voicelife_application/CMakeLists.txt | 1 + .../application/interaction_orchestrator.h | 56 +- .../src/interaction_orchestrator.cc | 24 +- components/voicelife_runtime/CMakeLists.txt | 18 +- components/voicelife_runtime/src/runtime.cc | 1992 +---------------- .../voicelife_runtime_esp/CMakeLists.txt | 20 +- .../runtime_esp/esp_interaction_task_host.h | 11 +- .../voicelife/runtime_esp/esp_runtime.h | 25 + .../src/bootstrap/storage_bootstrap.cc | 0 .../src/bootstrap/storage_bootstrap.h | 0 .../src/esp_interaction_task_host.cc | 8 +- .../voicelife_runtime_esp/src/esp_runtime.cc | 269 +++ .../src/esp_runtime_board.cc | 391 ++++ .../src/esp_runtime_event_loop.cc | 409 ++++ .../src/esp_runtime_interaction.cc | 460 ++++ .../src/esp_runtime_internal.h | 308 +++ .../src/esp_runtime_workers.cc | 249 +++ .../src/im_binding_mcp_tools.cc | 0 .../src/im_binding_mcp_tools.h | 0 .../src/im_binding_polling_lease.h | 0 .../src/im_binding_presentation.cc | 0 .../src/im_binding_presentation.h | 0 .../src/im_runtime_bootstrap.cc | 0 .../src/im_runtime_bootstrap.h | 0 .../src/linx_mcp_bridge.cc | 0 .../src/linx_mcp_bridge.h | 0 .../src/linx_ota_bootstrap.cc | 0 .../src/linx_ota_bootstrap.h | 0 .../src/linx_ota_device.inc | 0 .../src/mcp_worker_policy.h | 0 .../src/wifi_provisioning.cc | 0 .../src/wifi_provisioning.h | 0 .../src/wifi_provisioning_esp.cc | 0 .../src/wifi_provisioning_esp.h | 0 scripts/check_architecture.cmake | 8 +- tests/host/CMakeLists.txt | 21 +- tests/host/interaction_orchestrator_test.cc | 70 +- .../test_im_wifi_credential_isolation.py | 11 +- 38 files changed, 2254 insertions(+), 2097 deletions(-) create mode 100644 components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_runtime.h rename components/{voicelife_runtime => voicelife_runtime_esp}/src/bootstrap/storage_bootstrap.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/bootstrap/storage_bootstrap.h (100%) create mode 100644 components/voicelife_runtime_esp/src/esp_runtime.cc create mode 100644 components/voicelife_runtime_esp/src/esp_runtime_board.cc create mode 100644 components/voicelife_runtime_esp/src/esp_runtime_event_loop.cc create mode 100644 components/voicelife_runtime_esp/src/esp_runtime_interaction.cc create mode 100644 components/voicelife_runtime_esp/src/esp_runtime_internal.h create mode 100644 components/voicelife_runtime_esp/src/esp_runtime_workers.cc rename components/{voicelife_runtime => voicelife_runtime_esp}/src/im_binding_mcp_tools.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/im_binding_mcp_tools.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/im_binding_polling_lease.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/im_binding_presentation.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/im_binding_presentation.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/im_runtime_bootstrap.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/im_runtime_bootstrap.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/linx_mcp_bridge.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/linx_mcp_bridge.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/linx_ota_bootstrap.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/linx_ota_bootstrap.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/linx_ota_device.inc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/mcp_worker_policy.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/wifi_provisioning.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/wifi_provisioning.h (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/wifi_provisioning_esp.cc (100%) rename components/{voicelife_runtime => voicelife_runtime_esp}/src/wifi_provisioning_esp.h (100%) diff --git a/components/voicelife_application/CMakeLists.txt b/components/voicelife_application/CMakeLists.txt index e2f38404..dec1bc66 100644 --- a/components/voicelife_application/CMakeLists.txt +++ b/components/voicelife_application/CMakeLists.txt @@ -1,4 +1,5 @@ idf_component_register( SRCS "src/interaction_orchestrator.cc" INCLUDE_DIRS "include" + REQUIRES voicelife_contracts voicelife_voice ) diff --git a/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h b/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h index 105a1814..911d0c6b 100644 --- a/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h +++ b/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h @@ -1,35 +1,24 @@ #pragma once -#include +#include "voicelife/contracts/status.h" +#include "voicelife/voice/voice_interaction_controller.h" namespace voicelife::application { -/** @brief 交互应用层接收的跨域事件分类,不携带平台 SDK 类型。 */ -enum class InteractionEventKind : uint8_t { - kBootstrapRequested, - kBoardInputArrived, - kVoiceLifecycleChanged, - kConnectivityChanged, -}; - -/** @brief 一次交互编排请求的稳定输入模型。 */ +/** @brief 一次交互编排请求的稳定输入模型,不携带 ESP-IDF 或 FreeRTOS 类型。 */ struct InteractionEvent { - InteractionEventKind kind = InteractionEventKind::kBootstrapRequested; -}; - -/** @brief 交互编排器发给 Runtime Adapter 的稳定动作分类。 */ -enum class InteractionActionKind : uint8_t { - kInitializeInteraction, - kDispatchBoardInput, - kDispatchVoiceLifecycle, - kRefreshConnectivity, + voice::VoiceInteractionEvent voice_event = voice::VoiceInteractionEvent::kBootCompleted; }; -/** @brief 一次交互编排请求产生的动作。 */ +/** @brief 一次合法状态迁移产生的、可由平台适配器执行的语义动作。 */ struct InteractionAction { - InteractionActionKind kind = InteractionActionKind::kInitializeInteraction; + voice::VoiceInteractionEvent source = voice::VoiceInteractionEvent::kBootCompleted; + voice::VoiceInteractionState state = voice::VoiceInteractionState::kBooting; + voice::VoiceInteractionAction directive = voice::VoiceInteractionAction::kNone; - friend constexpr bool operator==(InteractionAction lhs, InteractionAction rhs) { return lhs.kind == rhs.kind; } + friend constexpr bool operator==(InteractionAction lhs, InteractionAction rhs) { + return lhs.source == rhs.source && lhs.state == rhs.state && lhs.directive == rhs.directive; + } }; /** @brief Runtime Adapter 实现的动作接收端口。 */ @@ -37,24 +26,35 @@ class InteractionActionSink { public: /** @brief 虚析构函数。 */ virtual ~InteractionActionSink() = default; - /** @brief 接收一个应用层编排动作。 @param action 要执行的语义动作。 */ - virtual void Submit(InteractionAction action) = 0; + /** + * @brief 接收一个应用层编排动作。 + * @param action 要执行的语义动作。 + * @return 动作投影结果。 + */ + virtual Status Submit(InteractionAction action) = 0; }; /** * @brief 平台无关的交互应用服务。 * - * 该骨架只定义跨域事件与动作的编排边界。既有 Runtime 事件循环仍保持原状; - * 后续迁移必须先用行为轨迹证明等价,才能将现有事件接入此服务。 + * 该服务拥有平台无关的交互状态机,并将合法状态迁移交给 Runtime Adapter + * 执行。FreeRTOS 队列、任务和定时器仍属于 ESP Runtime Adapter。 */ class InteractionOrchestrator { public: /** - * @brief 将一个跨域事件映射为一个确定的 Runtime Adapter 动作。 + * @brief 将一个交互事件映射为状态和确定的 Runtime Adapter 动作。 * @param event 要编排的跨域交互事件。 * @param actions 用于记录或执行动作的 Runtime Adapter 端口。 + * @return 状态机接受事件且动作投影成功时返回成功状态。 */ - void Handle(InteractionEvent event, InteractionActionSink& actions) const; + Status Handle(InteractionEvent event, InteractionActionSink& actions); + + /** @brief 返回最后一个已接受事件后的交互状态。 @return 当前交互状态。 */ + [[nodiscard]] voice::VoiceInteractionState state() const; + + private: + voice::VoiceInteractionController controller_; }; } // namespace voicelife::application diff --git a/components/voicelife_application/src/interaction_orchestrator.cc b/components/voicelife_application/src/interaction_orchestrator.cc index 006619c8..27995957 100644 --- a/components/voicelife_application/src/interaction_orchestrator.cc +++ b/components/voicelife_application/src/interaction_orchestrator.cc @@ -2,23 +2,15 @@ namespace voicelife::application { -void InteractionOrchestrator::Handle(InteractionEvent event, InteractionActionSink& actions) const { - InteractionActionKind action = InteractionActionKind::kInitializeInteraction; - switch (event.kind) { - case InteractionEventKind::kBootstrapRequested: - action = InteractionActionKind::kInitializeInteraction; - break; - case InteractionEventKind::kBoardInputArrived: - action = InteractionActionKind::kDispatchBoardInput; - break; - case InteractionEventKind::kVoiceLifecycleChanged: - action = InteractionActionKind::kDispatchVoiceLifecycle; - break; - case InteractionEventKind::kConnectivityChanged: - action = InteractionActionKind::kRefreshConnectivity; - break; +Status InteractionOrchestrator::Handle(InteractionEvent event, InteractionActionSink& actions) { + const auto transition = controller_.Handle(event.voice_event); + if (!transition.ok() || !transition.value.has_value()) { + return transition.status; } - actions.Submit({.kind = action}); + return actions.Submit( + {.source = event.voice_event, .state = transition.value->state, .directive = transition.value->action}); } +voice::VoiceInteractionState InteractionOrchestrator::state() const { return controller_.state(); } + } // namespace voicelife::application diff --git a/components/voicelife_runtime/CMakeLists.txt b/components/voicelife_runtime/CMakeLists.txt index 8deb09f7..c367d997 100644 --- a/components/voicelife_runtime/CMakeLists.txt +++ b/components/voicelife_runtime/CMakeLists.txt @@ -1,18 +1,6 @@ idf_component_register( - SRCS "src/runtime.cc" "src/bootstrap/storage_bootstrap.cc" "src/im_runtime_bootstrap.cc" - "src/linx_mcp_bridge.cc" "src/linx_ota_bootstrap.cc" "src/wifi_provisioning.cc" - "src/wifi_provisioning_esp.cc" - "src/im_binding_mcp_tools.cc" "src/im_binding_presentation.cc" - INCLUDE_DIRS "include" "src" + SRCS "src/runtime.cc" + INCLUDE_DIRS "include" REQUIRES voicelife_contracts - PRIV_REQUIRES voicelife_application voicelife_runtime_esp voicelife_mcp voicelife_voice voicelife_linx - voicelife_linx_esp voicelife_audio_esp - voicelife_display_esp voicelife_schedule voicelife_im voicelife_storage_fatfs - voicelife_storage_sqlite nvs_flash nvs_sec_provider esp_timer esp_http_client esp-tls esp_wifi - esp_netif lwip esp_event esp_http_server spi_flash esp_partition esp_psram esp_app_format esp_driver_gpio - esp_driver_usb_serial_jtag led_strip + PRIV_REQUIRES voicelife_runtime_esp ) - -if(NOT CONFIG_VOICELIFE_STORAGE_FATFS OR NOT CONFIG_VOICELIFE_STORAGE_SQLITE) - message(FATAL_ERROR "Runtime 日程持久化必须同时启用 VOICELIFE_STORAGE_FATFS 和 VOICELIFE_STORAGE_SQLITE") -endif() diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc index 771efe00..42dbb0a9 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -1,1997 +1,11 @@ #include "voicelife/runtime/runtime.h" -#include -#include -#include -#include -#include -#include -#include - -#ifdef ESP_PLATFORM -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "esp_heap_caps.h" -#include "esp_log.h" -#include "esp_timer.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "nvs.h" -#include "nvs_flash.h" -#include "voicelife/contracts/json.h" -#include "voicelife/im/esp_http_transport_factory.h" -#include "voicelife/im/im_binding_use_case.h" -#include "voicelife/im/im_config_store.h" -#include "voicelife/im/im_retry_policy.h" -#include "voicelife/im/im_runtime.h" -#include "voicelife/linx/linx_speech_provider.h" -#include "voicelife/linx/linx_types.h" -#include "voicelife/linx_esp/esp_websocket_transport.h" -#include "voicelife/mcp/mcp_server.h" -#include "voicelife/schedule/schedule_operation_service.h" -#include "voicelife/schedule/schedule_rule_service.h" -#include "voicelife/schedule/schedule_service.h" -#endif - -#include "bootstrap/storage_bootstrap.h" -#include "im_binding_mcp_tools.h" -#include "im_binding_polling_lease.h" -#include "im_binding_presentation.h" -#include "im_runtime_bootstrap.h" -#include "linx_mcp_bridge.h" -#include "linx_ota_bootstrap.h" -#include "mcp_worker_policy.h" -#include "voicelife/application/interaction_orchestrator.h" -#include "voicelife/mcp/schedule_mcp_tools.h" -#include "voicelife/runtime_esp/esp_interaction_task_host.h" -#include "voicelife/voice/display_snapshot.h" -#include "voicelife/voice/voice_interaction_controller.h" -#include "voicelife/voice/voice_ports.h" -#include "voicelife/voice/voice_session.h" +#include "voicelife/runtime_esp/esp_runtime.h" namespace voicelife::runtime { -namespace { - -#ifdef ESP_PLATFORM -constexpr char kTag[] = "VoiceLifeRuntime"; -constexpr int64_t kWakeAckDisplayUs = 400 * 1000; -constexpr int64_t kVolumeOverlayUs = 1500 * 1000; -// 唤醒或 follow-up 后的首次开口等待:6 秒足以让用户听清提示并开口, -// 又不会让无输入回合长时间占住 UI。说话后的端点与最终 STT 分别处理。 -constexpr uint32_t kListenStartTimeoutMs = 6000; -constexpr uint32_t kFinalSttTimeoutMs = 5000; -#if CONFIG_VOICELIFE_IM_GATEWAY -constexpr bool kImGatewayEnabled = true; -#else -constexpr bool kImGatewayEnabled = false; -#endif - -#if CONFIG_NVS_ENCRYPTION -Result ReadNvsString(nvs_handle_t handle, const char* key) { - size_t required = 0; - esp_err_t error = nvs_get_str(handle, key, nullptr, &required); - if (error != ESP_OK || required <= 1) { - return Result::Failure(ErrorCode::kNotFound, std::string("缺少 Linx NVS 配置: ") + key); - } - std::string value(required, '\0'); - error = nvs_get_str(handle, key, value.data(), &required); - if (error != ESP_OK) { - return Result::Failure(ErrorCode::kUnavailable, "读取 Linx NVS 配置失败"); - } - value.resize(required > 0 ? required - 1 : 0); - if (value.empty()) { - return Result::Failure(ErrorCode::kInvalidArgument, std::string("Linx NVS 配置为空: ") + key); - } - return Result::Success(std::move(value)); -} -#endif - -class NvsSecretResolver final : public linx_esp::SecretResolverPort { - public: - Result Resolve(std::string_view reference) override { -#if !CONFIG_NVS_ENCRYPTION - (void)reference; - return Result::Failure(ErrorCode::kUnavailable, "Linx token 解析需要启用 NVS encryption"); -#else - constexpr std::string_view prefix = "nvs://"; - if (reference.rfind(prefix, 0) != 0) { - return Result::Failure(ErrorCode::kInvalidArgument, "Linx token 引用必须使用 nvs://"); - } - const std::string path(reference.substr(prefix.size())); - const auto separator = path.find('/'); - if (separator == std::string::npos || separator == 0 || separator + 1 >= path.size()) { - return Result::Failure(ErrorCode::kInvalidArgument, "Linx token 引用格式无效"); - } - nvs_handle_t handle = 0; - const esp_err_t open_error = nvs_open_from_partition(LinxSecretPartitionLabel(), - path.substr(0, separator).c_str(), NVS_READONLY, &handle); - if (open_error != ESP_OK) { - return Result::Failure(ErrorCode::kNotFound, "Linx token NVS 命名空间不可用"); - } - auto result = ReadNvsString(handle, path.substr(separator + 1).c_str()); - nvs_close(handle); - return result; -#endif - } -}; - -#endif - -class ScaffoldAudioInput final : public voice::AudioInputPort { - public: - void SetAudioSink(voice::AudioFrameSink) override {} - Status Open(const voice::AudioFormat&) override { return Status::Ok(); } - Status StartCapture(voice::VoiceMode) override { return Status::Ok(); } - Status StopCapture() override { return Status::Ok(); } - void Close() override {} -}; - -class ScaffoldAudioOutput final : public voice::AudioOutputPort { - public: - Status Open(const voice::AudioFormat&) override { return Status::Ok(); } - Status Push(const voice::AudioFrame&) override { return Status::Ok(); } - Status Flush() override { return Status::Ok(); } - bool IsIdle() const override { return true; } - void Close() override {} -}; - -class ScaffoldSpeechProvider final : public voice::SpeechProviderAdapter { - public: - Status Connect(const voice::VoiceSessionConfig&, voice::VoiceEventSink) override { return Status::Ok(); } - Status StartCapture(voice::VoiceMode) override { return Status::Ok(); } - Status StopCapture() override { return Status::Ok(); } - Status SendAudio(const voice::AudioFrame&) override { return Status::Ok(); } - Status Abort(std::string_view) override { return Status::Ok(); } - Status Speak(std::string_view) override { return Status::Ok(); } - Status NotifyLocalWakeWord(std::string_view, std::string_view = {}) override { return Status::Ok(); } - Status Disconnect() override { return Status::Ok(); } - Result audio_formats() const override { - voice::VoiceAudioFormats fmt; - fmt.capture = voice::AudioFormat{}; - fmt.playback = voice::AudioFormat{}; - return Result::Success(fmt); - } - const voice::CapabilityProfile& capabilities() const override { return profile_; } - - private: - voice::CapabilityProfile profile_{"scaffold", {"streaming-asr", "tts"}}; -}; - -class Runtime final { - public: - /** @brief 构造运行时并将日程服务绑定到持久化仓储。 */ - Runtime() -#ifdef ESP_PLATFORM - : schedule_service_(storage_.GetScheduleRepository()), - schedule_operation_service_(storage_.GetScheduleOperationRepository()), - schedule_rule_service_(storage_.GetScheduleRuleRepository(), storage_.GetScheduleExceptionRepository(), - storage_.GetScheduleRepository()) -#endif - { - auto& registry = voice::SpeechProviderRegistry::Instance(); -#ifdef ESP_PLATFORM - init_status_ = mcp::RegisterScheduleMcpTools(mcp_server_, schedule_service_, schedule_rule_service_); - if (init_status_.ok()) { - // MCP worker 只产生绑定结果;轮询与 OLED/TTS 均由各自受控任务处理。 - init_status_ = - RegisterImBindingMcpTools(mcp_server_, binding_use_case_, [this](const im::BindingResult& result) { - EnqueueBindingResult(result); - if (result.state == im::BindingState::kPending) StartBindingPolling(result.generation); - }); - } - if (init_status_.ok()) { - ESP_LOGI(kTag, - "MCP_TOOLS_READY count=5 names=schedule.create,schedule.query,schedule.update,schedule.delete," - "im.binding.start"); - } - registry.Register("xrobot-websocket", linx::LinxSpeechProviderAdapter::DefaultCapabilities(), [this]() { - return std::make_unique( - *linx_transport_, linx_codec_, linx_config_, linx::LinxSpeechProviderAdapter::DefaultCapabilities(), - [this](std::string_view payload, std::string_view session_id) { - return HandleMcpRequest(payload, session_id); - }); - }); -#endif - registry.Register("scaffold", voice::CapabilityProfile{"scaffold", {"streaming-asr", "tts"}}, - []() { return std::make_unique(); }); - } - - Status Start(PlatformAssembly& assembly) { - assembly_ = &assembly; - const auto fail_startup = [this](Status status) { -#ifdef ESP_PLATFORM - StopMcpWorker(); - StopEventLoop(); -#endif - return status; - }; - auto& registry = voice::SpeechProviderRegistry::Instance(); - if (!init_status_.ok()) return init_status_; - const Status storage_status = storage_.Start(); - if (!storage_status.ok()) return storage_status; -#ifdef ESP_PLATFORM - // 立创实战派 ESP32-S3 板载 WS2812 灯珠接 GPIO48(小智 BUILTIN_LED_GPIO)。 - // 主 NVS 分区初始化(Wi-Fi 驱动/凭据等依赖;linx_secrets 为加密分区另行初始化)。 - { - esp_err_t nvs_error = nvs_flash_init(); - if (nvs_error == ESP_ERR_NVS_NO_FREE_PAGES || nvs_error == ESP_ERR_NVS_NEW_VERSION_FOUND) { - (void)nvs_flash_erase(); - nvs_error = nvs_flash_init(); - } - if (nvs_error != ESP_OK) { - ESP_LOGE(kTag, "STARTUP_ERROR stage=nvs_flash_init code=%d", static_cast(nvs_error)); - return Status::Error(ErrorCode::kInternal, "主 NVS 初始化失败"); - } - } - // 板级 LED 初始化(板型专属,Assembly 持有)。 - assembly_->InitializeBoardLeds(); - if (const Status display_status = assembly_->Start(); !display_status.ok()) { - ESP_LOGE(kTag, "STARTUP_ERROR stage=display_start code=%d msg=%s", static_cast(display_status.code), - display_status.message.c_str()); - return display_status; - } - // 显示启动后立即启动唯一的交互/显示语义写者。此后的启动、网络、音量 - // 和会话事件均只投递到该循环,不允许 Runtime 直接 Render。 - { - std::lock_guard lock(event_mutex_); - event_queue_.clear(); - event_loop_stop_ = false; - event_loop_stopped_ = false; - } - if (xTaskCreate(&Runtime::EventLoopTaskEntry, "voicelife_interaction", 8192, this, 5, &event_task_) != pdPASS) { - return Status::Error(ErrorCode::kInternal, "创建交互事件循环任务失败"); - } - if (const Status mcp_worker = StartMcpWorker(); !mcp_worker.ok()) { - return fail_startup(mcp_worker); - } - ShowDisplay(voice::VoiceMood::kConnecting, "联网", ""); - if (const Status secret_store = InitializeLinxSecretStore(); !secret_store.ok()) { - ESP_LOGW(kTag, "STARTUP_ERROR stage=secret_store code=%d", static_cast(secret_store.code)); - ShowDisplay(voice::VoiceMood::kSad, "错误", ""); - return fail_startup(secret_store); - } -#if CONFIG_VOICELIFE_IM_GATEWAY - // USB IM provisioning 不依赖 Wi-Fi;即使网络配置缺失并进入 SoftAP,也必须开放物理恢复窗口。 - if (!StartImProvisioningTask()) { - ESP_LOGW(kTag, "IM_PROVISION_TASK_FAILED=1"); - } -#endif - auto connection = BootstrapLinxOtaConfig(assembly_->board_identity(), - [this](std::string_view title, std::string_view detail) { - ShowDisplay(voice::VoiceMood::kConnecting, title, detail); - }); - // Bootstrap 无论是下发连接配置还是返回“待控制台激活”,均可能已经 - // 完成 STA 关联。由 Runtime 把受控网络事实写入快照,Renderer 只显示 - // 语义而不触碰 ESP Wi-Fi API。 - EnqueueNetworkState(LinxWifiStaConnected()); - if (!connection.ok() || !connection.value.has_value()) { - ESP_LOGW(kTag, "STARTUP_ERROR stage=linx_bootstrap code=%d", static_cast(connection.status.code)); - ShowDisplay(voice::VoiceMood::kSad, "错误", ""); - return fail_startup(connection.status); - } - ShowDisplay(voice::VoiceMood::kConnecting, "连接", ""); - linx_config_ = std::move(*connection.value); - // IM 的 SNTP、Gateway 探针和退避全部在独立任务中完成,语音启动路径不等待网络。 - StartImRuntime(); - auto result = registry.Create("xrobot-websocket", {}); -#else - auto result = registry.Create("scaffold", {}); -#endif - if (!result.ok() || !result.value.has_value()) { - ESP_LOGW(kTag, "STARTUP_ERROR stage=provider_create code=%d", static_cast(result.status.code)); - return fail_startup(Status::Error(ErrorCode::kInternal, "无法创建语音 Provider: " + result.status.message)); - } - provider_ = std::move(*result.value); - -#ifdef ESP_PLATFORM - // 音频端口由 Assembly 注入(业务 PCM 语义,不暴露 I2S/Codec)。 - assembly_->SetOutputVolume(static_cast(volume_)); - if (assembly_->uses_local_wake_detector()) { - assembly_->wake_gate().SetWakeSink([this](std::string_view wake_word) { QueueWakeWord(wake_word); }); - } - session_ = std::make_unique( - assembly_->wake_gate(), assembly_->audio_output(), *provider_, - [this](const voice::VoiceEvidence& evidence) { LogVoiceEvidence(evidence); }); - voice::VoiceSessionConfig config; - config.session_id = "voicelife-linx-session"; - config.provider_id = "xrobot-websocket"; - config.mode = voice::VoiceMode::kRealtime; - config.audio.codec = voice::AudioCodec::kPcmS16Le; - config.audio.sample_rate_hz = 16000; - config.audio.channels = 1; - config.audio.bits_per_sample = 16; - config.audio.frame_duration_ms = 20; -#else - session_ = std::make_unique(audio_input_, audio_output_, *provider_); - voice::VoiceSessionConfig config; - config.session_id = "scaffold-session"; - config.provider_id = "scaffold"; -#endif - const Status session_status = session_->Start(config); - if (!session_status.ok()) { - ESP_LOGW(kTag, "STARTUP_ERROR stage=session_start code=%d", static_cast(session_status.code)); - ShowDisplay(voice::VoiceMood::kSad, "错误", ""); - return fail_startup(session_status); - } - -#ifdef ESP_PLATFORM - if (wake_queue_ == nullptr) { - wake_queue_ = xQueueCreate(4, sizeof(BoardRequest)); - if (wake_queue_ == nullptr) return fail_startup(Status::Error(ErrorCode::kInternal, "创建唤醒队列失败")); - const BaseType_t task_status = - xTaskCreate(&Runtime::WakeTaskEntry, "voicelife_wake", 4096, this, 5, &wake_task_); - if (task_status != pdPASS) return fail_startup(Status::Error(ErrorCode::kInternal, "创建唤醒控制任务失败")); - } - EnqueueEvent(voice::VoiceInteractionEvent::kBootCompleted); - const Status input_status = - assembly_->StartBoardInput([this](BoardInputAction action) { EnqueueBoardInput(action); }); - if (!input_status.ok()) return fail_startup(input_status); -#if CONFIG_VOICELIFE_STATE_FLOW_TEST - if (const Status state_flow_status = StartStateFlowDiagnostic(); !state_flow_status.ok()) { - return fail_startup(state_flow_status); - } -#endif -#endif - return Status::Ok(); - } - - private: - StorageBootstrap storage_; -#ifdef ESP_PLATFORM - void StopEventLoop() { - if (event_task_ == nullptr) return; - { - std::lock_guard lock(event_mutex_); - event_queue_.clear(); - event_loop_stop_ = true; - } - event_cv_.notify_one(); - for (int attempt = 0; attempt < 20 && !event_loop_stopped_; ++attempt) { - vTaskDelay(pdMS_TO_TICKS(10)); - } - event_task_ = nullptr; - } - - struct McpRequest { - std::string payload; - std::string session_id; - std::mutex mutex; - std::condition_variable completed_cv; - std::optional> response; - bool completed = false; - std::atomic_bool abandoned{false}; - }; - - static constexpr std::size_t kMcpWorkerQueueCapacity = 4; - - Status StartMcpWorker() { - std::lock_guard lock(mcp_mutex_); - if (mcp_task_ != nullptr) { - // 旧 worker 可能仍在执行网络请求;未确认退出前不得重建,避免双 worker - // 并发访问队列、MCP server 与 BindingUseCase。 - if (!mcp_stopped_.load()) { - return Status::Error(ErrorCode::kInternal, "MCP 工作任务尚未退出"); - } - mcp_task_ = nullptr; // 任务已自删,仅句柄残留。 - } - mcp_stop_ = false; - mcp_stopped_.store(false); - if (xTaskCreate(&Runtime::McpWorkerTaskEntry, "voicelife_mcp", 32768, this, 4, &mcp_task_) != pdPASS) { - return Status::Error(ErrorCode::kInternal, "创建 MCP 工作任务失败"); - } - ESP_LOGI(kTag, "MCP_WORKER_READY capacity=%u", static_cast(kMcpWorkerQueueCapacity)); - return Status::Ok(); - } - - void StopMcpWorker() { - { - std::lock_guard lock(mcp_mutex_); - if (mcp_task_ == nullptr) return; - mcp_stop_ = true; - for (const auto& request : mcp_queue_) request->abandoned.store(true); - mcp_queue_.clear(); - } - mcp_cv_.notify_all(); - // 有界等待任务确认退出。worker 内 HTTPS 请求最长约 10s(传输层超时), - // 等待上限给足 5s;仍未退出时保留句柄并报错,拒绝在旧任务存续期重建。 - constexpr int kStopWaitAttempts = 500; - for (int attempt = 0; attempt < kStopWaitAttempts && !mcp_stopped_.load(); ++attempt) { - vTaskDelay(pdMS_TO_TICKS(10)); - } - if (mcp_stopped_.load()) { - std::lock_guard lock(mcp_mutex_); - mcp_task_ = nullptr; - } else { - ESP_LOGE(kTag, "MCP_WORKER_STOP_TIMEOUT=1 task_still_running=1"); - } - } - - // ---- im.binding.start 有界后台轮询 ---- - static constexpr uint32_t kBindingPollIntervalMs = 3000; - // Poll 内含 HTTPS 查询与 JSON 解析,但无 MCP/Linx 调用链;栈按 16KB 预留, - // 需以真机 uxTaskGetStackHighWaterMark 实测校准(任务退出时已上报高水位)。 - static constexpr uint32_t kBindingPollStackBytes = 16384; - - void StartBindingPolling(uint64_t generation) { - if (!binding_poll_lease_.Acquire(generation)) { - ESP_LOGI(kTag, "IM_BINDING_POLL_ADOPTED generation=%llu", static_cast(generation)); - return; - } - if (xTaskCreate(&Runtime::BindingPollTaskEntry, "voicelife_binding_poll", kBindingPollStackBytes, this, 2, - nullptr) != pdPASS) { - if (binding_poll_lease_.Release(generation)) { - EnqueueBindingResult(binding_use_case_.AbortPending(generation)); - } - ESP_LOGW(kTag, "IM_BINDING_POLL_TASK_FAILED=1"); - return; - } - ESP_LOGI(kTag, "IM_BINDING_POLL_STARTED generation=%llu", static_cast(generation)); - } - - static void BindingPollTaskEntry(void* context) { static_cast(context)->BindingPollLoop(); } - - void BindingPollLoop() { - while (true) { - const uint64_t owner_generation = binding_poll_lease_.generation(); - vTaskDelay(pdMS_TO_TICKS(kBindingPollIntervalMs)); - const im::BindingResult result = binding_use_case_.Poll(); - if (result.state == im::BindingState::kPending || result.state == im::BindingState::kWaiting || - result.state == im::BindingState::kRetrying) { - continue; - } - // 轮询任务只投递脱敏语义结果。事件循环按 BindingUseCase generation - // 丢弃 origin/凭据变更后迟到的旧 confirmed,绝不直接访问显示或语音硬件。 - EnqueueBindingResult(result); - // 终态或会话已释放。若新 Start 在旧任务退出窗口接管租约,Release - // 会失败,本任务继续服务新会话,避免出现 pending 却没有轮询任务。 - if (binding_use_case_.active()) continue; - if (binding_poll_lease_.Release(owner_generation)) { - ESP_LOGI(kTag, "IM_BINDING_STATUS=%s stack_high_water=%u", BindingStatusName(result.state), - static_cast(uxTaskGetStackHighWaterMark(nullptr))); - break; - } - } - ESP_LOGI(kTag, "IM_BINDING_POLL_STOPPED=1"); - vTaskDelete(nullptr); - } - - static std::string TruncateUtf8(std::string_view value, std::size_t max_bytes) { - if (value.size() <= max_bytes) return std::string(value); - std::size_t end = max_bytes; - while (end > 0 && (static_cast(value[end]) & 0xC0U) == 0x80U) --end; - return std::string(value.substr(0, end)) + "..."; - } - - static bool IsMcpToolCall(std::string_view payload) { - JsonValue request; - if (!ParseJson(payload, request).ok() || !request.IsObject()) return false; - const JsonValue* method = request.Get("method"); - return method != nullptr && method->IsString() && method->string == "tools/call"; - } - - Result HandleMcpRequest(std::string_view payload, std::string_view session_id) { - auto request = std::make_shared(); - request->payload.assign(payload); - request->session_id.assign(session_id); - { - std::lock_guard lock(mcp_mutex_); - if (mcp_stop_ || mcp_task_ == nullptr || mcp_queue_.size() >= kMcpWorkerQueueCapacity) { - ESP_LOGW(kTag, "MCP_REQUEST_REJECTED reason=queue_full"); - return BuildLinxMcpUnavailableResponse(payload, "设备 MCP 正忙,请稍后重试", session_id); - } - mcp_queue_.push_back(request); - } - ESP_LOGI(kTag, "MCP_REQUEST_QUEUED bytes=%u", static_cast(payload.size())); - mcp_cv_.notify_one(); - - std::unique_lock lock(request->mutex); - if (!request->completed_cv.wait_for(lock, std::chrono::milliseconds(kMcpResponseTimeoutMs), - [&] { return request->completed; })) { - request->abandoned.store(true); - ESP_LOGW(kTag, "MCP_REQUEST_REJECTED reason=timeout"); - return BuildLinxMcpUnavailableResponse(payload, "设备 MCP 响应超时", session_id); - } - return std::move(*request->response); - } - - static void McpWorkerTaskEntry(void* arg) { static_cast(arg)->McpWorkerLoop(); } - - void McpWorkerLoop() { - while (true) { - std::shared_ptr request; - { - std::unique_lock lock(mcp_mutex_); - mcp_cv_.wait(lock, [this] { return mcp_stop_ || !mcp_queue_.empty(); }); - if (mcp_stop_ && mcp_queue_.empty()) break; - request = std::move(mcp_queue_.front()); - mcp_queue_.pop_front(); - } - if (request->abandoned.load()) continue; - const bool tool_call = IsMcpToolCall(request->payload); - if (tool_call && session_) session_->ReportToolCallStarted(); - auto response = HandleLinxMcpPayload(request->payload, mcp_server_, request->session_id); - if (!response.ok()) { - response = BuildLinxMcpUnavailableResponse(request->payload, "设备 MCP 执行失败", request->session_id); - } - if (tool_call && !request->abandoned.load() && session_) { - const LinxMcpToolOutcome outcome = InspectLinxMcpToolOutcome(request->payload, response); - session_->ReportToolResult(TruncateUtf8(outcome.summary, 96), outcome.success); - } - ESP_LOGI(kTag, "MCP_TOOL_EXECUTED tool_call=%d result=%d", tool_call ? 1 : 0, response.ok() ? 1 : 0); - { - std::lock_guard lock(request->mutex); - if (!request->abandoned.load()) { - request->response = std::move(response); - request->completed = true; - } - } - request->completed_cv.notify_one(); - } - mcp_stopped_.store(true); - vTaskDelete(nullptr); - } - - void StartImRuntime() { -#if CONFIG_VOICELIFE_IM_GATEWAY - bool expected = false; - if (!im_lifecycle_started_.compare_exchange_strong(expected, true)) return; - if (xTaskCreate(&Runtime::ImLifecycleTaskEntry, "voicelife_im_lifecycle", 8192, this, 3, &im_lifecycle_task_) != - pdPASS) { - im_lifecycle_started_.store(false); - ESP_LOGW(kTag, "IM_RUNTIME_TASK_FAILED=1"); - } -#else - ESP_LOGI(kTag, "IM_RUNTIME_DISABLED=1"); -#endif - } - - static void ImLifecycleTaskEntry(void* context) { static_cast(context)->ImLifecycleTask(); } - - void ImLifecycleTask() { - im::ImRetryPolicy retry_policy; - while (true) { - Status status = Status::Error(ErrorCode::kUnavailable, "IM Runtime 等待网络"); - im::ImHttpResponse response{.status = im::ImTransportStatus::kNetworkFailure, - .status_code = 0, - .body = {}, - .message = "IM 前置条件未就绪"}; - - if (im_readiness_.NetworkReady() && !im_readiness_.SystemTimeReady()) { - status = SynchronizeSystemTime(); - } - status = im_runtime_.Start(); - if (im_runtime_.state() == im::ImRuntimeState::kProbing) { - response = im_runtime_.ProbeGateway(); - if (im_runtime_.state() != im::ImRuntimeState::kReady) { - status = Status::Error(ErrorCode::kUnavailable, "IM Gateway 认证探针失败"); - } - } - - if (im_runtime_.state() == im::ImRuntimeState::kReady) { - // 选择 #235 的“重启后重新开始”策略:不恢复任何旧会话;下一次 - // 明确语音命令会创建新会话,Gateway 会原子取消同设备旧 pending。 - binding_use_case_.Bind(*im_runtime_.pairing_client(), im_pairing_clock_, im_runtime_.user_id()); - EnqueueBindingReset(binding_use_case_.generation()); - RegisterImPairingAcceptance(im_runtime_.pairing_client(), im_runtime_.device_id(), - im_runtime_.user_id()); - ESP_LOGI(kTag, "IM_RUNTIME_READY=1"); - break; - } - if (im_runtime_.state() == im::ImRuntimeState::kDisabled) { - ESP_LOGI(kTag, "IM_RUNTIME_DISABLED=1"); - break; - } - if (im_runtime_.state() == im::ImRuntimeState::kUnconfigured) { - ESP_LOGW(kTag, "IM_RUNTIME_DEGRADED=1 state=%d code=%d", static_cast(im_runtime_.state()), - static_cast(status.code)); - break; - } - - ESP_LOGW(kTag, "IM_RUNTIME_DEGRADED=1 state=%d code=%d http_status=%d", - static_cast(im_runtime_.state()), static_cast(status.code), response.status_code); - const auto delay_ms = retry_policy.NextDelay(response); - if (!delay_ms.has_value()) break; - ESP_LOGI(kTag, "IM_RUNTIME_RETRY attempt=%u delay_ms=%u", static_cast(retry_policy.attempts()), - static_cast(*delay_ms)); - vTaskDelay(pdMS_TO_TICKS(*delay_ms)); - } - vTaskDelete(nullptr); - } - - enum class BoardRequestKind : uint8_t { - kWakeWord, - kInterruptAndWakeWord, - kRestoreStandby, - kInterrupt, - kStartCapture, - kStopCapture, - kInterruptAndStartCapture, - }; - - struct BoardRequest { - BoardRequestKind kind = BoardRequestKind::kRestoreStandby; - char wake_word[32]; - /** 物理唤醒门已就绪后是否需将 Controller 收口为 standby。 */ - bool settle_controller = true; - /** 当存在时,以 Provider 的正式 TTS 请求播报这段系统话术。 */ - char system_speech[kBindingSystemSpeechCapacity]; - }; - - void EnqueueBoardInput(BoardInputAction action) { - InteractionEventItem item{}; - item.board_input = true; - item.board_action = action; - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - void SetVolume(int volume) { - volume_ = std::clamp(volume, 0, 100); - if (assembly_ != nullptr) assembly_->SetOutputVolume(volume_); - // 音量通知 overlay:临时覆盖显示,1.5s 后恢复最新快照(不修改会话状态)。 - // 连续调音量只重置同一个计时器。 - char text[16] = {}; - std::snprintf(text, sizeof(text), "VOL:%d", volume_); - ShowOverlay(voice::VoiceMood::kIdle, "音量", text); - volume_overlay_until_us_ = esp_timer_get_time() + kVolumeOverlayUs; - if (volume_overlay_timer_ == nullptr) { - esp_timer_create_args_t args = {}; - args.callback = &VolumeOverlayEntry; - args.arg = this; - args.name = "voicelife_volume_overlay"; - (void)esp_timer_create(&args, &volume_overlay_timer_); - } - if (volume_overlay_timer_ != nullptr) { - (void)esp_timer_stop(volume_overlay_timer_); - (void)esp_timer_start_once(volume_overlay_timer_, kVolumeOverlayUs); - } - } - - void QueueWakeWord(std::string_view wake_word) { - LogVoiceEvidence({.session_id = session_ ? session_->config().session_id : "", - .generation = session_ ? session_->generation() : 0, - .event = "wake_detected", - .detail = {}}); - // “别说了”要中止旧播报后只回复一次“收到!”,随即转入聆听;它不是 - // 静默中止,也不能被当作普通唤醒后让旧 TTS 继续播放。 - const auto event = wake_word == "别说了" ? voice::VoiceInteractionEvent::kInterruptAndAcknowledge - : voice::VoiceInteractionEvent::kWakeDetected; - EnqueueEvent(event, wake_word); - } - - void QueueVoiceTurn(std::string_view wake_word) { - if (wake_queue_ == nullptr) return; - BoardRequest request{}; - request.kind = BoardRequestKind::kWakeWord; - const std::size_t size = - wake_word.size() < sizeof(request.wake_word) - 1 ? wake_word.size() : sizeof(request.wake_word) - 1; - std::memcpy(request.wake_word, wake_word.data(), size); - request.wake_word[size] = '\0'; - (void)xQueueSend(wake_queue_, &request, 0); - } - - void QueueInterruptAndVoiceTurn(std::string_view wake_word) { - if (wake_queue_ == nullptr) return; - BoardRequest request{}; - request.kind = BoardRequestKind::kInterruptAndWakeWord; - const std::size_t size = - wake_word.size() < sizeof(request.wake_word) - 1 ? wake_word.size() : sizeof(request.wake_word) - 1; - std::memcpy(request.wake_word, wake_word.data(), size); - request.wake_word[size] = '\0'; - (void)xQueueSend(wake_queue_, &request, 0); - } - - void QueueStandbyRecovery(bool settle_controller = true) { - if (wake_queue_ == nullptr) return; - BoardRequest recovery{}; - recovery.settle_controller = settle_controller; - (void)xQueueSend(wake_queue_, &recovery, 0); - } - - bool QueueSystemSpeech(std::string_view text) { - if (wake_queue_ == nullptr || text.empty()) return false; - if (text.size() >= kBindingSystemSpeechCapacity) { - ESP_LOGE(kTag, "SYSTEM_SPEECH_TOO_LONG bytes=%u", static_cast(text.size())); - return false; - } - BoardRequest request{}; - request.kind = BoardRequestKind::kInterrupt; - std::memcpy(request.system_speech, text.data(), text.size()); - request.system_speech[text.size()] = '\0'; - if (xQueueSend(wake_queue_, &request, 0) != pdTRUE) { - ESP_LOGW(kTag, "SYSTEM_SPEECH_QUEUE_FULL=1"); - return false; - } - return true; - } - - // 下行长文本滚动由显示 Adapter 负责(Ssd1306PresentationAdapter)。 - // 音量 overlay 到期:递增 revision 触发 CommitSnapshot 恢复最新快照。 - static void VolumeOverlayEntry(void* context) { - auto* self = static_cast(context); - self->volume_overlay_until_us_ = 0; - self->overlay_expired_.store(true); // 只置标志;恢复由事件循环唯一执行。 - } - - // 聆听/最终 STT 超时: - // - kListening 超时(无有效输入):结束本轮回待机 - // - kFinalizing 超时(listen.stop 后 5s 无最终 STT):abort 结束服务端回合回待机 - static void ListenTimeoutEntry(void* context) { - auto* self = static_cast(context); - // Timer 回调不能读取或迁移交互状态;由事件循环串行决定超时路径。 - ESP_LOGI(kTag, "LISTEN_TIMEOUT_FIRED"); - self->EnqueueListenTimeout(); - } - - void StartListenTimer(uint32_t timeout_ms) { - if (listen_timer_ == nullptr) { - esp_timer_create_args_t args = {}; - args.callback = &ListenTimeoutEntry; - args.arg = this; - args.name = "voicelife_listen_timeout"; - if (esp_timer_create(&args, &listen_timer_) != ESP_OK) { - listen_timer_ = nullptr; - return; - } - } - (void)esp_timer_stop(listen_timer_); - const esp_err_t start = esp_timer_start_once(listen_timer_, timeout_ms * 1000ULL); - if (start != ESP_OK) { - ESP_LOGW(kTag, "LISTEN_TIMEOUT_ARM_FAILED ms=%u err=%d", static_cast(timeout_ms), - static_cast(start)); - return; - } - ESP_LOGI(kTag, "LISTEN_TIMEOUT_ARMED ms=%u", static_cast(timeout_ms)); - } - - void CancelListenTimer() { - if (listen_timer_ != nullptr) { - (void)esp_timer_stop(listen_timer_); - } - } - - void QueueInterrupt() { - if (wake_queue_ == nullptr) return; - BoardRequest request{}; - request.kind = BoardRequestKind::kInterrupt; - (void)xQueueSend(wake_queue_, &request, 0); - } - - void QueueCaptureStart() { - if (wake_queue_ == nullptr) return; - BoardRequest request{}; - request.kind = BoardRequestKind::kStartCapture; - (void)xQueueSend(wake_queue_, &request, 0); - } - - void QueueCaptureStop() { - if (wake_queue_ == nullptr) return; - BoardRequest request{}; - request.kind = BoardRequestKind::kStopCapture; - (void)xQueueSend(wake_queue_, &request, 0); - } - - void QueueInterruptAndCapture() { - if (wake_queue_ == nullptr) return; - BoardRequest request{}; - request.kind = BoardRequestKind::kInterruptAndStartCapture; - (void)xQueueSend(wake_queue_, &request, 0); - } - -#if CONFIG_VOICELIFE_STATE_FLOW_TEST - Status StartStateFlowDiagnostic() { - if (state_flow_task_ != nullptr) return Status::Ok(); - if (xTaskCreate(&Runtime::StateFlowTaskEntry, "voicelife_state_flow", 4096, this, 1, &state_flow_task_) != - pdPASS) { - return Status::Error(ErrorCode::kInternal, "创建状态流诊断任务失败"); - } - ESP_LOGI(kTag, "STATE_FLOW_TEST_STARTED production_default=0"); - return Status::Ok(); - } - - static void StateFlowTaskEntry(void* context) { static_cast(context)->StateFlowTask(); } - - void StateFlowEvent(uint32_t step, voice::VoiceInteractionEvent event) { - ESP_LOGI(kTag, "STATE_FLOW_ENQUEUE step=%u kind=interaction event=%d", static_cast(step), - static_cast(event)); - EnqueueEvent(event); - } - - void StateFlowEvidence(uint32_t step, std::string_view event, std::string_view detail = {}) { - ESP_LOGI(kTag, "STATE_FLOW_ENQUEUE step=%u kind=evidence event=%.*s detail_bytes=%u", - static_cast(step), static_cast(event.size()), event.data(), - static_cast(detail.size())); - voice::VoiceEvidence evidence; - evidence.session_id = session_ ? session_->config().session_id : "state-flow"; - evidence.generation = session_ ? session_->generation() : 0; - evidence.event = std::string(event); - evidence.detail = std::string(detail); - EnqueueVoiceEvidence(evidence); - } - - void StateFlowTask() { - // Test-only diagnostic. It submits normal semantic inputs/evidence and - // never calls a renderer, PresentationPort, GPIO, or audio output. - vTaskDelay(pdMS_TO_TICKS(1500)); - uint32_t step = 1; - StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportDisconnected); - vTaskDelay(pdMS_TO_TICKS(350)); - StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportConnected); - vTaskDelay(pdMS_TO_TICKS(350)); - StateFlowEvent(step++, voice::VoiceInteractionEvent::kPressDown); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "capture_started"); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "stt_text_received", "请在明天 09:30 创建日程: Review #42, room A-3."); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "mcp_tool_started"); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "mcp_tool_result", "event=Review #42; status=created"); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "tts_started"); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "tts_sentence_started", "已创建日程。明天 09:30 在 A-3 开会。"); - vTaskDelay(pdMS_TO_TICKS(150)); - // A state-flow build must not invent a local TTS completion when no - // real PCM turn was opened. Exercise the production cancellation path - // instead: Runtime asks VoiceSession to interrupt and only its real - // completion restores standby. - StateFlowEvent(step++, voice::VoiceInteractionEvent::kInterruptRequested); - vTaskDelay(pdMS_TO_TICKS(500)); - for (uint32_t cycle = 0; cycle < 20; ++cycle) { - StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportDisconnected); - vTaskDelay(pdMS_TO_TICKS(90)); - StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportConnected); - vTaskDelay(pdMS_TO_TICKS(90)); - } - StateFlowEvent(step++, voice::VoiceInteractionEvent::kPressDown); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "capture_started"); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvent(step++, voice::VoiceInteractionEvent::kFailure); - vTaskDelay(pdMS_TO_TICKS(300)); - StateFlowEvent(step++, voice::VoiceInteractionEvent::kStandbyReady); - vTaskDelay(pdMS_TO_TICKS(300)); - StateFlowEvent(step++, voice::VoiceInteractionEvent::kPressDown); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvidence(step++, "capture_started"); - vTaskDelay(pdMS_TO_TICKS(150)); - StateFlowEvent(step++, voice::VoiceInteractionEvent::kInterruptRequested); - vTaskDelay(pdMS_TO_TICKS(150)); - // kInterruptRequested reaches VoiceSession, whose real interrupted - // evidence restores standby through the event loop. Do not inject a - // second completion after that recovery: it is necessarily stale and - // would make this diagnostic report a false ordering rejection. - ESP_LOGI(kTag, "STATE_FLOW_TEST_FINISHED steps=%u", static_cast(step - 1)); - state_flow_task_ = nullptr; - vTaskDelete(nullptr); - } -#endif - - void RestoreStandby(const BoardRequest& request) { - if (assembly_ == nullptr) return; - const Status stop_status = assembly_->wake_gate().StopCapture(); - if (!stop_status.ok()) { - ESP_LOGW(kTag, "本地待机恢复停止上行失败: %s", stop_status.message.c_str()); - (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); - return; - } - const Status standby_status = assembly_->wake_gate().StartStandby(); - if (!standby_status.ok()) { - ESP_LOGW(kTag, "本地待机恢复失败: %s", standby_status.message.c_str()); - (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); - return; - } - LogVoiceEvidence({.session_id = session_ ? session_->config().session_id : "", - .generation = session_ ? session_->generation() : 0, - .event = "standby_ready", - .detail = {}}); - // 显式派发 kStandbyReady:Controller 从 Error/kFinalizing 回 Standby, - // 避免 RestoreStandby 直接写快照造成控制器仍停 Error 的假待机 - // (WAKE_REARM atomic=0)。Controller 回 Standby 后由状态机动作 - // 统一提交时间快照。 - // 事件化:状态迁移由事件循环唯一执行,拒绝日志在事件循环统一输出。 - if (request.settle_controller) { - EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); - } - } - - static void WakeTaskEntry(void* context) { static_cast(context)->WakeTask(); } - - void WakeTask() { - BoardRequest request{}; - while (true) { - if (xQueueReceive(wake_queue_, &request, portMAX_DELAY) != pdTRUE) continue; - if (request.kind == BoardRequestKind::kRestoreStandby) { - RestoreStandby(request); - continue; - } - if (request.kind == BoardRequestKind::kInterruptAndWakeWord) { - if (!session_ || !provider_) continue; - const Status acknowledge = session_->InterruptAndNotifyLocalWakeWord(request.wake_word, "收到!"); - if (!acknowledge.ok()) { - ESP_LOGW(kTag, "打断确认请求失败: %s", acknowledge.message.c_str()); - QueueStandbyRecovery(); - } - continue; - } - if (request.kind == BoardRequestKind::kInterrupt) { - if (!session_) continue; - const Status interrupt = session_->Interrupt(); - if (request.system_speech[0] != '\0') { - const Status speak = interrupt.ok() ? session_->Speak(request.system_speech) : interrupt; - if (!speak.ok()) { - ESP_LOGW(kTag, "系统播报请求失败: %s", speak.message.c_str()); - QueueStandbyRecovery(); - } - continue; - } - if (interrupt.ok()) { - if (interaction_.state() == voice::VoiceInteractionState::kInterrupting) { - (void)EnqueueEvent(voice::VoiceInteractionEvent::kInterruptCompleted); - } else { - QueueStandbyRecovery(); - } - } else { - ESP_LOGW(kTag, "板端打断失败: %s", interrupt.message.c_str()); - QueueStandbyRecovery(); - } - continue; - } - if (request.kind == BoardRequestKind::kStartCapture) { - // 开麦前等待播放排空(I2S 实际播完,而非队列空),避免把残留 - // TTS 重新采进 follow-up(NoAudioCodec 无 AEC)。 - if (assembly_ != nullptr) { - for (int i = 0; i < 30 && !assembly_->audio_output().IsIdle(); ++i) { - vTaskDelay(pdMS_TO_TICKS(50)); - } - } - const Status capture = - session_ ? session_->BeginCapture() : Status::Error(ErrorCode::kUnavailable, "语音会话尚未启动"); - if (!capture.ok()) { - ESP_LOGW(kTag, "板级按键开始采集失败: %s", capture.message.c_str()); - // 事务式启动失败:回待机(kStandbyReady),不显示"出错了/牛牛走了"。 - (void)EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); - } - continue; - } - if (request.kind == BoardRequestKind::kStopCapture) { - const Status stop = - session_ ? session_->EndCapture() : Status::Error(ErrorCode::kUnavailable, "语音会话尚未启动"); - if (!stop.ok()) { - ESP_LOGW(kTag, "板级按键结束采集失败: %s", stop.message.c_str()); - (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); - } else { - // 仅当已离开 kFinalizing(VAD 端点后等待最终 STT 中)才恢复待机: - // kFinalizing 表示本轮还在等最终 STT/TTS,不能提前回待机。 - // 其余(聆听正常结束、超时、按键停止)恢复待机。 - if (interaction_.state() != voice::VoiceInteractionState::kFinalizing) { - QueueStandbyRecovery(); - } - } - continue; - } - if (request.kind == BoardRequestKind::kInterruptAndStartCapture) { - if (!session_) continue; - const Status interrupt = session_->Interrupt(); - const Status capture = interrupt.ok() ? session_->BeginCapture() : interrupt; - if (!capture.ok()) { - ESP_LOGW(kTag, "板级打断后开始采集失败: %s", capture.message.c_str()); - // 打断后启动失败:回待机,不显示"出错了/牛牛走了"。 - (void)EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); - } - continue; - } - if (!session_ || !provider_) continue; - // Linx 官方协议支持 listen.detect.text_response:服务端真实合成 - // “收到!”并下发协商 PCM,tts.stop 后 Controller 才开始聆听。 - const Status acknowledge = session_->NotifyLocalWakeWord(request.wake_word, "收到!"); - if (!acknowledge.ok()) { - ESP_LOGW(kTag, "唤醒确认请求失败: %s", acknowledge.message.c_str()); - // 唤醒启动失败:回待机,不显示"出错了/牛牛走了"。 - (void)EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); - } - } - } - - // 显示模型:由会话阶段推导可见状态,仅在 revision 变化时提交渲染器。 - // phase→状态栏文本 与 mood 映射集中在此,不再散落在各事件分支。 - static std::string_view PhaseStatusText(voice::VoiceInteractionState state) { - switch (state) { - case voice::VoiceInteractionState::kBooting: - return "开机"; - case voice::VoiceInteractionState::kStandby: - return "空闲"; - case voice::VoiceInteractionState::kOpeningCapture: - return "聆听中"; // 采集请求提交中(事务式启动过渡) - case voice::VoiceInteractionState::kListening: - return "聆听中"; - case voice::VoiceInteractionState::kFinalizing: - return "聆听中"; // 等待最终 STT,仍显示聆听 - case voice::VoiceInteractionState::kThinking: - return "处理中"; - case voice::VoiceInteractionState::kSpeaking: - return "说话中"; - case voice::VoiceInteractionState::kInterrupting: - return "停止"; - case voice::VoiceInteractionState::kReconnecting: - return "重连中"; - case voice::VoiceInteractionState::kError: - return "出错了"; - } - return "出错了"; - } - - static voice::VoiceMood PhaseMood(voice::VoiceInteractionState state) { - switch (state) { - case voice::VoiceInteractionState::kBooting: - return voice::VoiceMood::kBooting; - case voice::VoiceInteractionState::kStandby: - return voice::VoiceMood::kIdle; - case voice::VoiceInteractionState::kOpeningCapture: - case voice::VoiceInteractionState::kListening: - case voice::VoiceInteractionState::kFinalizing: - return voice::VoiceMood::kListening; - case voice::VoiceInteractionState::kThinking: - return voice::VoiceMood::kThinking; - case voice::VoiceInteractionState::kSpeaking: - return voice::VoiceMood::kSpeaking; - case voice::VoiceInteractionState::kInterrupting: - return voice::VoiceMood::kCancelled; - case voice::VoiceInteractionState::kReconnecting: - return voice::VoiceMood::kConnecting; - case voice::VoiceInteractionState::kError: - return voice::VoiceMood::kSad; - } - return voice::VoiceMood::kSad; - } - - static std::string CurrentStandbyStatusText() { - const time_t now = time(nullptr); - if (now <= 1600000000) return "空闲"; // 2020-09-13 之前视为尚未同步时钟。 - std::tm local{}; - localtime_r(&now, &local); - char clock_text[8] = {}; - std::snprintf(clock_text, sizeof(clock_text), "%02d:%02d", local.tm_hour, local.tm_min); - return clock_text; - } - - void CommitSnapshot() { - if (snapshot_.revision == last_rendered_revision_) { - return; - } - last_rendered_revision_ = snapshot_.revision; - // 显示语义通过 PresentationPort 提交;渲染由板级 Adapter 完成。 - if (assembly_ != nullptr) { - (void)assembly_->presentation().Render(snapshot_); - } - ESP_LOGI(kTag, - "INTERACTION_SNAPSHOT phase=%d generation=%llu revision=%llu mood=%d status_bytes=%u role=%d " - "content_bytes=%u", - static_cast(snapshot_.phase), static_cast(snapshot_.generation), - static_cast(snapshot_.revision), static_cast(snapshot_.mood), - static_cast(snapshot_.status_text.size()), static_cast(snapshot_.role), - static_cast(snapshot_.content_text.size())); - } - - // 显示语义提交:只投递给 InteractionEventLoop,禁止在调用线程直接 Render。 - void ShowDisplay(voice::VoiceMood mood, std::string_view status, std::string_view content) { - EnqueueDisplayUpdate(mood, status, content, false); - } - - // 临时 overlay 快照:由事件循环统一写入,revision 与业务快照保持严格单调。 - void ShowOverlay(voice::VoiceMood mood, std::string_view status, std::string_view content) { - EnqueueDisplayUpdate(mood, status, content, true); - } - - void StartOverlayTimer(uint32_t duration_ms) { - volume_overlay_until_us_ = esp_timer_get_time() + static_cast(duration_ms) * 1000; - if (volume_overlay_timer_ == nullptr) { - esp_timer_create_args_t args = {}; - args.callback = &VolumeOverlayEntry; - args.arg = this; - args.name = "voicelife_overlay"; - (void)esp_timer_create(&args, &volume_overlay_timer_); - } - if (volume_overlay_timer_ != nullptr) { - (void)esp_timer_stop(volume_overlay_timer_); - (void)esp_timer_start_once(volume_overlay_timer_, static_cast(duration_ms) * 1000ULL); - } - } - - // “收到!”是唤醒确认的短暂显示。即使服务端暂时没有后续语音事件, - // 也必须由事件循环在租约到期后主动刷新,否则 OLED 会永久保留确认文本。 - void ClearExpiredWakeAck() { - if (wake_ack_until_us_ == 0 || esp_timer_get_time() < wake_ack_until_us_) return; - wake_ack_until_us_ = 0; - if (snapshot_.phase != voice::VoiceInteractionState::kListening || - snapshot_.role != voice::VoiceContentRole::kSystem || snapshot_.content_text != "收到!") { - return; - } - snapshot_.content_text.clear(); - snapshot_.role = voice::VoiceContentRole::kNone; - ++snapshot_.revision; - CommitSnapshot(); - ESP_LOGI(kTag, "WAKE_ACK_DISPLAY_EXPIRED=1"); - } - - Status HandleInteractionEvent(voice::VoiceInteractionEvent event, std::string_view wake_word = {}) { - const auto transition = interaction_.Handle(event); - if (!transition.ok() || !transition.value.has_value()) { - ESP_LOGW(kTag, "忽略乱序板端交互事件=%d: %s", static_cast(event), transition.status.message.c_str()); - return transition.status; - } - // 新回合事件递增语义代次:显示任务按 generation -> revision 丢弃迟到快照。 - switch (event) { - case voice::VoiceInteractionEvent::kToggleChat: - case voice::VoiceInteractionEvent::kPressDown: - case voice::VoiceInteractionEvent::kWakeDetected: - case voice::VoiceInteractionEvent::kInterruptAndAcknowledge: - ++snapshot_.generation; - // A fresh user turn must never inherit a farewell decision - // from a disconnected or cancelled preceding turn. - terminal_turn_ = false; - binding_turn_awaiting_tts_completion_ = false; - break; - case voice::VoiceInteractionEvent::kInterruptRequested: - case voice::VoiceInteractionEvent::kTransportDisconnected: - case voice::VoiceInteractionEvent::kFailure: - // These paths invalidate the current remote turn before its - // normal TTS completion can safely decide the next UI state. - terminal_turn_ = false; - binding_turn_awaiting_tts_completion_ = false; - break; - default: - break; - } - // 会话阶段 → 显示模型快照:状态栏文本 + 表情由阶段派生。 - snapshot_.phase = interaction_.state(); - snapshot_.mood = PhaseMood(snapshot_.phase); - if (snapshot_.phase != voice::VoiceInteractionState::kStandby && binding_terminal_display_active_) { - CancelBindingTerminalDisplay(); - } - // 空闲态显示当前时间(若服务端时间已初始化),否则显示状态词。 - if (snapshot_.phase == voice::VoiceInteractionState::kStandby) { - snapshot_.status_text = CurrentStandbyStatusText(); - } else { - snapshot_.status_text = PhaseStatusText(snapshot_.phase); - } - // 事件驱动的内容角色切换: - // - kIntentReceived(STT):内容栏显示用户语音,角色 user - // - kTtsStarted:内容栏保持/显示助手文本,角色 assistant - // - 会话结束/回待机:清空内容栏 - // WakeAck 租约:唤醒后短窗(400ms)内显示“收到!”,不阻塞开麦。 - if ((event == voice::VoiceInteractionEvent::kWakeDetected || - event == voice::VoiceInteractionEvent::kInterruptAndAcknowledge) && - snapshot_.phase == voice::VoiceInteractionState::kListening && wake_ack_until_us_ > 0 && - esp_timer_get_time() < wake_ack_until_us_) { - snapshot_.content_text = "收到!"; - snapshot_.role = voice::VoiceContentRole::kSystem; - } else if (event == voice::VoiceInteractionEvent::kEndpointDetected) { - // VAD 端点:进入 kFinalizing 等待最终 STT,清掉“收到!”残留, - // 显示“聆听中”状态词。 - wake_ack_until_us_ = 0; - snapshot_.content_text.clear(); - snapshot_.role = voice::VoiceContentRole::kNone; - } else if (event == voice::VoiceInteractionEvent::kIntentReceived && !stt_display_text_.empty()) { - snapshot_.content_text = stt_display_text_; - snapshot_.role = voice::VoiceContentRole::kUser; - } else if (event == voice::VoiceInteractionEvent::kTtsStopped || - event == voice::VoiceInteractionEvent::kStandbyReady || - event == voice::VoiceInteractionEvent::kBootCompleted) { - snapshot_.content_text.clear(); - snapshot_.role = voice::VoiceContentRole::kNone; - } - // 绑定码不是一帧临时字幕。普通语音回合可以覆盖它,但回到待机后必须 - // 恢复当前 pending 会话的六码与有效期,直到 Gateway 返回终态。 - if (snapshot_.phase == voice::VoiceInteractionState::kStandby && binding_display_active_ && - binding_display_generation_ == binding_use_case_.generation()) { - snapshot_.mood = voice::VoiceMood::kNeutral; - snapshot_.status_text = binding_status_text_; - snapshot_.content_text = binding_content_text_; - snapshot_.role = voice::VoiceContentRole::kSystem; - } - // 冗余 standby_ready 不得让绑定终态一闪而过;进入任何活跃状态 - // 会在上方取消租约,使新交互立即接管显示。 - if (snapshot_.phase == voice::VoiceInteractionState::kStandby && binding_terminal_display_active_) { - snapshot_.mood = binding_terminal_mood_; - snapshot_.status_text = binding_terminal_status_text_; - snapshot_.content_text = binding_terminal_content_text_; - snapshot_.role = voice::VoiceContentRole::kSystem; - } - ++snapshot_.revision; - // 真实状态迁移优先于临时 overlay,过期信号不能恢复旧回合的 UI。 - overlay_active_ = false; - CommitSnapshot(); - QueueDeferredBindingSpeechIfStandby(); - switch (transition.value->action) { - case voice::VoiceInteractionAction::kNone: - return Status::Ok(); - case voice::VoiceInteractionAction::kStartCapture: - QueueCaptureStart(); - return Status::Ok(); - case voice::VoiceInteractionAction::kStartVoiceTurn: - if (wake_word.empty()) { - return Status::Error(ErrorCode::kInvalidArgument, "本地唤醒词不能为空"); - } - QueueVoiceTurn(wake_word); - return Status::Ok(); - case voice::VoiceInteractionAction::kStopVoiceTurn: - QueueCaptureStop(); - return Status::Ok(); - case voice::VoiceInteractionAction::kInterruptAndStartCapture: - QueueInterruptAndCapture(); - return Status::Ok(); - case voice::VoiceInteractionAction::kInterruptAndStartVoiceTurn: - if (wake_word.empty()) { - return Status::Error(ErrorCode::kInvalidArgument, "本地打断词不能为空"); - } - QueueInterruptAndVoiceTurn(wake_word); - return Status::Ok(); - case voice::VoiceInteractionAction::kRestoreStandby: - // transport_disconnected 必须停在 kReconnecting;物理唤醒门可恢复, - // 但不可用 kStandbyReady 把可见状态提前伪装为空闲。 - QueueStandbyRecovery(interaction_.state() != voice::VoiceInteractionState::kReconnecting); - return Status::Ok(); - case voice::VoiceInteractionAction::kInterruptSession: - QueueInterrupt(); - return Status::Ok(); - } - return Status::Error(ErrorCode::kInternal, "未知板端交互动作"); - } - - void LogVoiceEvidence(const voice::VoiceEvidence& evidence) { EnqueueVoiceEvidence(evidence); } - - void ProcessVoiceEvidence(const voice::VoiceEvidence& evidence) { - // Evidence detail can contain STT text or service diagnostics. Emit - // only lifecycle names and numeric counters needed for board review. - if (evidence.event == "capture_started") { - capture_started_us_.store(esp_timer_get_time()); - StartListenTimer(kListenStartTimeoutMs); - } - const int64_t started_at = capture_started_us_.load(); - const int64_t now = esp_timer_get_time(); - const uint64_t latency_ms = - started_at > 0 && now >= started_at ? static_cast((now - started_at) / 1000) : 0; - if (assembly_ != nullptr) assembly_->LogAudioStats(); - ESP_LOGI(kTag, "VOICE_HEAP event=%s internal_free=%u internal_largest=%u psram_free=%u", evidence.event.c_str(), - static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)), - static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)), - static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM))); - ESP_LOGI(kTag, "VOICE_EVENT session=%s generation=%llu event=%s detail_present=%d latency_from_capture_ms=%llu", - evidence.session_id.c_str(), static_cast(evidence.generation), - evidence.event.c_str(), evidence.detail.empty() ? 0 : 1, static_cast(latency_ms)); - if (evidence.event == "provider_error") { - // 板端诊断:只输出本地错误消息(不包含 STT 文本、凭据或原始响应)。 - ESP_LOGW(kTag, "PROVIDER_ERROR_DETAIL=%.160s", evidence.detail.c_str()); - } - if (evidence.event == "tts_started" && wake_ack_requested_at_us_ > 0) { - const int64_t wake_latency_ms = (esp_timer_get_time() - wake_ack_requested_at_us_) / 1000; - if (wake_latency_ms >= 0 && wake_latency_ms <= 10000) { - wake_ack_tts_started_at_us_ = esp_timer_get_time(); - ESP_LOGI(kTag, "WAKE_ACK_LATENCY stage=tts_started ms=%lld", static_cast(wake_latency_ms)); - } - } else if (evidence.event == "tts_first_audio" && wake_ack_tts_started_at_us_ > 0) { - const int64_t audio_latency_ms = (esp_timer_get_time() - wake_ack_requested_at_us_) / 1000; - ESP_LOGI(kTag, "WAKE_ACK_LATENCY stage=first_audio ms=%lld", static_cast(audio_latency_ms)); - } else if (evidence.event == "tts_stopped" && wake_ack_tts_started_at_us_ > 0) { - // 只关闭已经确认属于本次唤醒提示的计时窗口;后续回答的 TTS - // 不得被误归类为首次确认时延。 - wake_ack_requested_at_us_ = 0; - wake_ack_tts_started_at_us_ = 0; - } - if (evidence.event == "tts_stopped" || evidence.event == "tts_aborted" || evidence.event == "provider_error" || - evidence.event == "capture_stop_failed" || evidence.event == "tts_capture_stop_failed") { - capture_started_us_.store(0); - } - if (evidence.event == "capture_started") { - (void)EnqueueEvent(voice::VoiceInteractionEvent::kCaptureStarted); - } else if (evidence.event == "stt_text_received") { - // 收到用户语音转写(STT):取消聆听超时,等待服务端回复。 - CancelListenTimer(); - // 抑制唤醒词被回传为 STT:唤醒后 1.5s 内收到等于唤醒词的文本, - // 视为服务端把唤醒词误转写,不显示、不武装回复、不发 kIntentReceived。 - const bool wake_echo = !last_wake_word_.empty() && evidence.detail == last_wake_word_ && - (last_wake_at_ > 0 && esp_timer_get_time() - last_wake_at_ < 1500 * 1000LL); - if (wake_echo) { - ESP_LOGI(kTag, "WAKE_ECHO_SUPPRESSED"); - // 中止该合成回合,避免服务端据此生成问候 TTS;随后由聆听超时/新输入重启。 - if (session_) { - (void)session_->Interrupt(); - } - return; - } - // 回写用户说的话到屏幕(detail 是 ASR 文本,属于用户自己的输入)。 - if (!evidence.detail.empty()) { - stt_display_text_ = evidence.detail; - // 终止意图识别:再见/拜拜/bye 等 → 播报结束后不 follow-up,直接收尾。 - terminal_turn_ = (evidence.detail.find("再见") != std::string::npos || - evidence.detail.find("拜拜") != std::string::npos || - evidence.detail.find("bye") != std::string::npos || - evidence.detail.find("拜") != std::string::npos || - evidence.detail.find("走了") != std::string::npos); - } - (void)EnqueueEvent(voice::VoiceInteractionEvent::kIntentReceived); - if (terminal_turn_) { - // 不等待服务端针对“再见”的自由回复。先取消旧回合,再以 Linx - // text_response 请求固定告别语,因此只会播放“牛牛走了~”。 - QueueSystemSpeech("牛牛走了~"); - } - } else if (evidence.event == "tool_call_received") { - // MCP 工具调用(服务端发现/工具执行)不是用户语音意图: - // 仅取消聆听超时,不武装回复、不触发 kIntentReceived。 - CancelListenTimer(); - } else if (evidence.event == "mcp_tool_started") { - // MCP worker 只经 VoiceSession evidence 投递;状态机决定是否允许 - // 从当前交互态进入“处理中”,不得由 worker 自己写快照。 - CancelListenTimer(); - const auto phase = interaction_.state(); - if (phase == voice::VoiceInteractionState::kListening || - phase == voice::VoiceInteractionState::kFinalizing || - phase == voice::VoiceInteractionState::kThinking) { - (void)EnqueueEvent(voice::VoiceInteractionEvent::kIntentReceived); - } - } else if (evidence.event == "mcp_tool_result" || evidence.event == "mcp_tool_failed") { - const bool success = evidence.event == "mcp_tool_result"; - // 绑定工具由 BindingPresentation 显示真实绑定码/终态。通用工具 - // overlay 不得用“日程操作已完成”等摘要覆盖绑定页面。 - if (IsBindingMcpToolSummary(evidence.detail)) { - ESP_LOGI(kTag, "IM_BINDING_TOOL_OVERLAY_SUPPRESSED=1"); - return; - } - // evidence.detail 不是可信的用户文本。仅接受 MCP worker 产生的 - // 固定业务短句;任何原始 JSON-RPC/MCP 内容都降级为通用文案。 - std::string_view summary = success ? "操作已完成" : "操作失败"; - std::string_view status = success ? "操作结果" : "操作错误"; - if (success && evidence.detail == "日程已创建") { - summary = "日程已创建"; - status = "日程结果"; - } else if (success && evidence.detail == "日程查询完成") { - summary = "日程查询完成"; - status = "日程结果"; - } else if (!success && evidence.detail == "日程创建失败") { - summary = "日程创建失败"; - status = "日程错误"; - } else if (!success && evidence.detail == "日程查询失败") { - summary = "日程查询失败"; - status = "日程错误"; - } - ShowOverlay(success ? voice::VoiceMood::kHappy : voice::VoiceMood::kSad, status, summary); - StartOverlayTimer(2500); - } else if (evidence.event == "tts_started") { - CancelListenTimer(); - (void)EnqueueEvent(voice::VoiceInteractionEvent::kTtsStarted); - } else if (evidence.event == "local_wake_ack_requested" || evidence.event == "interrupt_ack_requested") { - // 本地唤醒/打断确认已经成功提交给 Provider,但真正的 tts.start - // 可能永远不到达(断线或服务端无响应)。此时 UI 已处于 - // kListening,必须有边界地回到待机,不能无限显示“聆听中”。 - StartListenTimer(kListenStartTimeoutMs); - } else if (evidence.event == "tts_sentence_started") { - // 回写服务端回复句子到屏幕(detail 为 TTS 文本),并立即提交快照 - // 让“说话中 + 助手文本”可见(不再停留显示用户 STT)。 - // 门控:仅当 Controller 已接受 kTtsStarted(处于 kSpeaking)才改显示; - // 迟到的 TTS(Controller 已回 Standby/Error)直接丢弃,避免绕过状态机 - // 把屏幕卡在“说话中”。 - if (interaction_.state() != voice::VoiceInteractionState::kSpeaking) { - ESP_LOGI(kTag, "TTS_SENTENCE_STALE state=%d 丢弃迟到句子", static_cast(interaction_.state())); - return; - } - CancelListenTimer(); - if (!evidence.detail.empty()) { - // 事件化:文本经事件循环应用(唯一写者),门控仍在事件循环校验。 - stt_display_text_ = evidence.detail; - EnqueueDisplayText(evidence.detail); - } - } else if (evidence.event == "tts_stopped" || evidence.event == "tts_aborted") { - CancelListenTimer(); - // Provider disconnect/reconnect may deliver the completion of an - // already-aborted remote TTS turn. It has no visible meaning once - // the interaction loop has restored standby (or entered another - // terminal state), so it must not re-enter the controller and - // produce a false ordering error. - if (interaction_.state() != voice::VoiceInteractionState::kSpeaking) { - ESP_LOGI(kTag, "TTS_STOPPED_STALE state=%d 丢弃迟到结束事件", static_cast(interaction_.state())); - return; - } - if (terminal_turn_ || binding_turn_awaiting_tts_completion_) { - // 告别或绑定码播报完成后直接恢复待机。绑定码页面会在 - // HandleInteractionEvent 的待机呈现规则中立即恢复。 - terminal_turn_ = false; - binding_turn_awaiting_tts_completion_ = false; - (void)EnqueueEvent(voice::VoiceInteractionEvent::kTerminalResponseCompleted); - } else { - // 事件化:kTtsStopped 由事件循环唯一执行状态迁移。 - EnqueueEvent(voice::VoiceInteractionEvent::kTtsStopped); - } - } else if (evidence.event == "transport_disconnected") { - CancelListenTimer(); - (void)EnqueueEvent(voice::VoiceInteractionEvent::kTransportDisconnected); - } else if (evidence.event == "transport_connected") { - (void)EnqueueEvent(voice::VoiceInteractionEvent::kTransportConnected); - } else if (evidence.event == "provider_error" || evidence.event == "capture_stop_failed" || - evidence.event == "tts_capture_stop_failed") { - CancelListenTimer(); - // 会话已回待机后收到的 provider_error(如服务端有序 FIN/断开)是 - // 正常断线,不当作故障;随后的 transport_disconnected 走自动重连。 - // 仅会话进行中(聆听/处理/播报)的 provider_error 才算真正故障。 - const auto phase = interaction_.state(); - if (phase != voice::VoiceInteractionState::kStandby) { - (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); - } - } else if (evidence.event == "capture_stopped") { - // kFinalizing(等最终 STT)时不得取消 5s 最终 STT 定时器, - // 否则服务端不返回 STT 时会永久悬挂;其余状态取消。 - if (interaction_.state() != voice::VoiceInteractionState::kFinalizing) { - CancelListenTimer(); - } - } else if (evidence.event == "vad_silence") { - // 本地 VAD 端点:用户说完话后静音 1200ms,发 listen.stop 使服务端 - // 进入最终 STT,然后等待最终 STT(kFinalizing),不回待机。 - // 启动 5s 最终 STT 超时:无 STT 则 abort 收尾。 - CancelListenTimer(); - if (interaction_.state() == voice::VoiceInteractionState::kListening) { - (void)EnqueueEvent(voice::VoiceInteractionEvent::kEndpointDetected); - StartListenTimer(kFinalSttTimeoutMs); - } - } - } - - NvsSecretResolver linx_secrets_; - NvsImSecretStore im_secret_store_; - im::StoredImConfigProvider im_config_{im_secret_store_, kImGatewayEnabled}; - EspImRuntimeReadiness im_readiness_; - im::ImRuntime im_runtime_{im_config_, im_config_, im_readiness_, - [](const std::string& origin) { return im::CreateEspHttpTransport(origin); }}; - EspPairingClock im_pairing_clock_; - im::BindingUseCase binding_use_case_; - BindingPollingLease binding_poll_lease_; - bool binding_display_active_ = false; - uint64_t binding_display_generation_ = 0; - std::string binding_status_text_; - std::string binding_content_text_; - std::optional deferred_binding_presentation_; - std::string deferred_binding_speech_; - std::atomic_bool im_lifecycle_started_{false}; - TaskHandle_t im_lifecycle_task_ = nullptr; - mcp::McpServer mcp_server_; - schedule::ScheduleService schedule_service_; - schedule::ScheduleOperationService schedule_operation_service_; - schedule::ScheduleRuleService schedule_rule_service_; - Status init_status_ = Status::Ok(); - linx::LinxJsonCodec linx_codec_; - linx::LinxConnectionConfig linx_config_; - std::unique_ptr linx_transport_ = - std::make_unique(linx_secrets_); - QueueHandle_t wake_queue_ = nullptr; - TaskHandle_t wake_task_ = nullptr; -#if CONFIG_VOICELIFE_STATE_FLOW_TEST - TaskHandle_t state_flow_task_ = nullptr; -#endif - // 交互事件单写者(InteractionEventLoop):外部线程只投递事件。 - struct InteractionEventItem { - voice::VoiceInteractionEvent event = voice::VoiceInteractionEvent::kBootCompleted; - std::string wake_word; - /** @brief 纯显示刷新文本(display_only 时由事件循环应用,不走状态机)。 */ - std::string display_text; - /** @brief 是否为纯显示刷新(跳过 HandleInteractionEvent)。 */ - bool display_only = false; - /** 受控系统显示更新;只由事件循环转换为 DisplaySnapshot。 */ - bool display_update = false; - /** 是否为临时 overlay(音量/告别)。 */ - bool display_overlay = false; - voice::VoiceMood display_mood = voice::VoiceMood::kIdle; - std::string display_status; - std::string display_content; - /** VoiceSession/Provider 回调携带的业务事实,由事件循环处理。 */ - bool voice_evidence = false; - voice::VoiceEvidence evidence; - /** MCP/轮询任务产生的脱敏绑定结果;事件循环负责呈现与播报。 */ - bool binding_result = false; - im::BindingResult binding; - /** Runtime 依赖重绑后清除旧 pending 呈现。 */ - bool binding_reset = false; - uint64_t binding_generation = 0; - /** esp_timer 只投递,事件循环根据当前状态决定超时收尾。 */ - bool listen_timeout = false; - /** 启动/网络回调携带的受控连接事实。 */ - bool network_update = false; - bool network_connected = false; - /** 板级输入适配器的纯语义事件;由事件循环转换为状态或音量变更。 */ - bool board_input = false; - BoardInputAction board_action = BoardInputAction::kToggleChat; - }; - static constexpr std::size_t kEventQueueCapacity = 16; - std::deque event_queue_; - mutable std::mutex event_mutex_; - std::condition_variable event_cv_; - TaskHandle_t event_task_ = nullptr; - bool event_loop_stop_ = false; - bool event_loop_stopped_ = false; - /** @brief 音量 overlay 到期标志(timer 置位,事件循环消费)。 */ - std::atomic overlay_expired_{false}; - std::mutex mcp_mutex_; - std::condition_variable mcp_cv_; - std::deque> mcp_queue_; - TaskHandle_t mcp_task_ = nullptr; - bool mcp_stop_ = false; - std::atomic_bool mcp_stopped_{true}; - - /** @brief 投递交互事件(有界队列,满丢最旧;任何线程可调用)。 */ - void EnqueueEvent(voice::VoiceInteractionEvent event, std::string_view wake_word = {}) { - InteractionEventItem item{}; - item.event = event; - item.wake_word = std::string(wake_word); - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) { - event_queue_.pop_front(); - } - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - /** @brief 投递纯显示刷新(TTS 文本等,事件循环内应用,不触发状态机)。 */ - void EnqueueDisplayText(std::string detail) { - InteractionEventItem item{}; - item.display_only = true; - item.display_text = std::move(detail); - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) { - event_queue_.pop_front(); - } - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - /** @brief 投递启动/错误/overlay 等系统语义;不携带硬件资源或原始数据。 */ - void EnqueueDisplayUpdate(voice::VoiceMood mood, std::string_view status, std::string_view content, bool overlay) { - InteractionEventItem item{}; - item.display_update = true; - item.display_overlay = overlay; - item.display_mood = mood; - item.display_status = std::string(status); - item.display_content = std::string(content); - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - void EnqueueBindingResult(const im::BindingResult& result) { - InteractionEventItem item{}; - item.binding_result = true; - item.binding = result; - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - void EnqueueBindingReset(uint64_t generation) { - InteractionEventItem item{}; - item.binding_reset = true; - item.binding_generation = generation; - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - void CancelBindingTerminalDisplay() { - binding_terminal_display_active_ = false; - binding_terminal_resume_listening_ = false; - binding_terminal_until_us_ = 0; - binding_terminal_status_text_.clear(); - binding_terminal_content_text_.clear(); - } - - void ClearExpiredBindingTerminalDisplay() { - if (!binding_terminal_display_active_ || binding_terminal_until_us_ == 0 || - esp_timer_get_time() < binding_terminal_until_us_) { - return; - } - const bool resume_listening = binding_terminal_resume_listening_; - CancelBindingTerminalDisplay(); - deferred_binding_speech_.clear(); - if (interaction_.state() != voice::VoiceInteractionState::kStandby) return; - if (resume_listening) { - ESP_LOGI(kTag, "IM_BINDING_TERMINAL_DISPLAY_EXPIRED=1 next=listening"); - snapshot_.content_text.clear(); - snapshot_.role = voice::VoiceContentRole::kNone; - (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kToggleChat); - return; - } - snapshot_.phase = voice::VoiceInteractionState::kStandby; - snapshot_.mood = voice::VoiceMood::kIdle; - snapshot_.status_text = CurrentStandbyStatusText(); - snapshot_.content_text.clear(); - snapshot_.role = voice::VoiceContentRole::kNone; - ++snapshot_.revision; - overlay_active_ = false; - CommitSnapshot(); - ESP_LOGI(kTag, "IM_BINDING_TERMINAL_DISPLAY_EXPIRED=1"); - } - - void CommitBindingPresentation(const BindingPresentation& presentation) { - snapshot_.mood = - presentation.content_text == "绑定成功" ? voice::VoiceMood::kHappy : voice::VoiceMood::kNeutral; - snapshot_.status_text = presentation.status_text; - snapshot_.content_text = presentation.content_text; - snapshot_.role = voice::VoiceContentRole::kSystem; - ++snapshot_.revision; - overlay_active_ = false; - CommitSnapshot(); - if (presentation.display_duration_ms > 0) { - binding_terminal_display_active_ = true; - binding_terminal_mood_ = snapshot_.mood; - binding_terminal_status_text_ = presentation.status_text; - binding_terminal_content_text_ = presentation.content_text; - binding_terminal_resume_listening_ = presentation.resume_listening; - binding_terminal_until_us_ = - esp_timer_get_time() + static_cast(presentation.display_duration_ms) * 1000; - } else { - CancelBindingTerminalDisplay(); - } - } - - void QueueDeferredBindingSpeechIfStandby() { - if (interaction_.state() != voice::VoiceInteractionState::kStandby) return; - if (deferred_binding_presentation_.has_value()) { - CommitBindingPresentation(*deferred_binding_presentation_); - deferred_binding_presentation_.reset(); - } - if (deferred_binding_speech_.empty()) return; - std::string speech = std::move(deferred_binding_speech_); - deferred_binding_speech_.clear(); - if (!QueueSystemSpeech(speech)) deferred_binding_speech_ = std::move(speech); - } - - void ProcessBindingResult(const im::BindingResult& result) { - // Bind() increments the generation before replacing client/config dependencies. - // A completed HTTP query from the prior origin can therefore never show success - // after reconfiguration or an explicit restart. - const uint64_t current_generation = binding_use_case_.generation(); - if (!IsCurrentBindingResult(result, current_generation)) { - ESP_LOGI(kTag, "IM_BINDING_STALE_RESULT=1 result_generation=%llu current_generation=%llu", - static_cast(result.generation), - static_cast(current_generation)); - return; - } - const BindingPresentation presentation = PresentBindingResult(result); - if (!presentation.keep_visible && !presentation.announce) return; - - if (ShouldEndVoiceTurnAfterBindingResult(result, - interaction_.state() != voice::VoiceInteractionState::kStandby)) { - binding_turn_awaiting_tts_completion_ = true; - } - - binding_display_active_ = presentation.keep_visible; - binding_display_generation_ = result.generation; - if (presentation.keep_visible) { - binding_status_text_ = presentation.status_text; - binding_content_text_ = presentation.content_text; - } else { - binding_status_text_.clear(); - binding_content_text_.clear(); - } - // 终态在普通对话中抵达时,将 OLED 与 TTS 作为一个结果延后到待机。 - // 这不会抢写用户正在看的 STT 或助手回复。 - if (!presentation.keep_visible && interaction_.state() != voice::VoiceInteractionState::kStandby) { - deferred_binding_presentation_ = presentation; - deferred_binding_speech_ = presentation.speech_text; - return; - } - - CommitBindingPresentation(presentation); - if (!presentation.announce) return; - if (interaction_.state() == voice::VoiceInteractionState::kStandby) { - if (!QueueSystemSpeech(presentation.speech_text)) deferred_binding_speech_ = presentation.speech_text; - } else { - // 活跃 MCP 回合的响应已携带 speak_text,由 Provider 播报一次。 - // 不再延迟本地重复播报;该播报结束后会直接回待机显示绑定码。 - } - } - - void EnqueueVoiceEvidence(const voice::VoiceEvidence& evidence) { - InteractionEventItem item{}; - item.voice_evidence = true; - item.evidence = evidence; - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - void EnqueueListenTimeout() { - InteractionEventItem item{}; - item.listen_timeout = true; - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - void EnqueueNetworkState(bool connected) { - InteractionEventItem item{}; - item.network_update = true; - item.network_connected = connected; - { - std::lock_guard lock(event_mutex_); - if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); - event_queue_.push_back(std::move(item)); - } - event_cv_.notify_one(); - } - - /** @brief 事件循环任务入口(唯一调用 HandleInteractionEvent 的线程)。 */ - static void EventLoopTaskEntry(void* arg) { static_cast(arg)->EventLoopLoop(); } - - /** @brief 事件循环:消费事件 -> 状态迁移 -> 快照 -> 显示提交。 */ - void EventLoopLoop() { -#ifdef ESP_PLATFORM - while (true) { - InteractionEventItem item; - { - std::unique_lock lock(event_mutex_); - event_cv_.wait_for(lock, std::chrono::milliseconds(200), - [this] { return event_loop_stop_ || !event_queue_.empty(); }); - if (event_loop_stop_ && event_queue_.empty()) { - break; - } - if (event_queue_.empty()) { - // 超时轮询:处理短暂显示的到期刷新(不依赖 timer 直接提交)。 - ClearExpiredWakeAck(); - ClearExpiredBindingTerminalDisplay(); - if (overlay_expired_.exchange(false)) { - if (overlay_active_) { - snapshot_ = overlay_base_snapshot_; - ++snapshot_.revision; - overlay_active_ = false; - CommitSnapshot(); - } - } - continue; - } - item = std::move(event_queue_.front()); - event_queue_.pop_front(); - } - // provider_error 等事件持续占满队列时,终态租约仍必须按时收口。 - ClearExpiredBindingTerminalDisplay(); - if (item.display_only) { - // 纯显示刷新:仅当控制器处于 kSpeaking 时应用(迟到的 TTS 丢弃)。 - if (interaction_.state() == voice::VoiceInteractionState::kSpeaking && !item.display_text.empty()) { - snapshot_.content_text = item.display_text; - snapshot_.role = voice::VoiceContentRole::kAssistant; - snapshot_.status_text = "说话中"; - snapshot_.mood = voice::VoiceMood::kSpeaking; - ++snapshot_.revision; - CommitSnapshot(); - } - continue; - } - if (item.network_update) { - snapshot_.network_connected = item.network_connected; - continue; - } - if (item.board_input) { - switch (item.board_action) { - case BoardInputAction::kToggleChat: - (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kToggleChat); - break; - case BoardInputAction::kPressDown: - (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kPressDown); - break; - case BoardInputAction::kPressUp: - (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kPressUp); - break; - case BoardInputAction::kVolumeUp: - SetVolume(std::min(volume_ + 10, 100)); - break; - case BoardInputAction::kVolumeDown: - SetVolume(std::max(volume_ - 10, 0)); - break; - case BoardInputAction::kVolumeMaximum: - SetVolume(100); - break; - case BoardInputAction::kVolumeMute: - SetVolume(0); - break; - case BoardInputAction::kStartWifiProvisioning: { - ShowDisplay(voice::VoiceMood::kConnecting, "配网", "正在开启热点"); - const Status requested = RequestLinxWifiProvisioning(); - if (!requested.ok()) ShowDisplay(voice::VoiceMood::kSad, "配网失败", ""); - break; - } - } - continue; - } - if (item.voice_evidence) { - ProcessVoiceEvidence(item.evidence); - continue; - } - if (item.binding_result) { - ProcessBindingResult(item.binding); - continue; - } - if (item.binding_reset) { - if (item.binding_generation == binding_use_case_.generation()) { - binding_display_active_ = false; - binding_display_generation_ = item.binding_generation; - binding_status_text_.clear(); - binding_content_text_.clear(); - deferred_binding_presentation_.reset(); - deferred_binding_speech_.clear(); - binding_turn_awaiting_tts_completion_ = false; - CancelBindingTerminalDisplay(); - // 重绑/重启策略不允许旧 origin 的绑定码或成功提示留在屏幕上。 - // 非空闲回合会由紧随其后的交互事件接管显示;空闲时立即收口。 - if (interaction_.state() == voice::VoiceInteractionState::kStandby) { - snapshot_.mood = voice::VoiceMood::kIdle; - snapshot_.status_text = CurrentStandbyStatusText(); - snapshot_.content_text.clear(); - snapshot_.role = voice::VoiceContentRole::kNone; - ++snapshot_.revision; - overlay_active_ = false; - CommitSnapshot(); - } - } - continue; - } - if (item.listen_timeout) { - if (interaction_.state() == voice::VoiceInteractionState::kListening) { - // 实机麦克风底噪可能让本地 VAD 未能识别静音端点,但此前 - // 已采集的语音仍必须以 listen.stop 交给服务端完成最终 STT。 - // 直接 abort 会无条件丢弃该回合,表现为“收到后不再回应”。 - ESP_LOGI(kTag, "LISTEN_TIMEOUT transition=listening->finalizing"); - (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kEndpointDetected); - StartListenTimer(kFinalSttTimeoutMs); - } else if (interaction_.state() == voice::VoiceInteractionState::kFinalizing) { - ESP_LOGI(kTag, "FINALIZE_TIMEOUT transition=finalizing->standby"); - if (session_) (void)session_->Interrupt(); - (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kFinalizationTimedOut); - } - continue; - } - if (item.display_update) { - if (item.display_overlay) { - overlay_base_snapshot_ = snapshot_; - overlay_active_ = true; - } else { - overlay_active_ = false; - } - snapshot_.mood = item.display_mood; - snapshot_.status_text = std::move(item.display_status); - snapshot_.content_text = std::move(item.display_content); - snapshot_.role = voice::VoiceContentRole::kSystem; - ++snapshot_.revision; - CommitSnapshot(); - ESP_LOGI(kTag, "DISPLAY_SEMANTIC_UPDATE overlay=%d mood=%d generation=%llu revision=%llu", - item.display_overlay ? 1 : 0, static_cast(snapshot_.mood), - static_cast(snapshot_.generation), - static_cast(snapshot_.revision)); - continue; - } - if (item.event == voice::VoiceInteractionEvent::kWakeDetected || - item.event == voice::VoiceInteractionEvent::kInterruptAndAcknowledge) { - // 唤醒前置(唯一状态写者内):显示租约;声音由 Linx TTS 的 - // text_response 产生,绝不在 Runtime 直接推裸 PCM。 - last_wake_word_ = item.wake_word; - last_wake_at_ = esp_timer_get_time(); - wake_ack_requested_at_us_ = last_wake_at_; - wake_ack_tts_started_at_us_ = 0; - wake_ack_until_us_ = esp_timer_get_time() + kWakeAckDisplayUs; - } - const Status wake_status = HandleInteractionEvent(item.event, item.wake_word); - if (item.event != voice::VoiceInteractionEvent::kWakeDetected && !wake_status.ok()) { - ESP_LOGW(kTag, "INTERACTION_REJECTED event=%d state=%d err=%s", static_cast(item.event), - static_cast(interaction_.state()), wake_status.message.c_str()); - } - if ((item.event == voice::VoiceInteractionEvent::kWakeDetected || - item.event == voice::VoiceInteractionEvent::kInterruptAndAcknowledge || - item.event == voice::VoiceInteractionEvent::kInterruptRequested) && - !wake_status.ok()) { - // 非法本地命令(例如待机时“别说了”)不得让检测器停死。 - ESP_LOGW(kTag, "LOCAL_COMMAND_REJECTED state=%d err=%s", static_cast(interaction_.state()), - wake_status.message.c_str()); - if (assembly_->uses_local_wake_detector()) { - (void)assembly_->wake_gate().StartStandby(); - } - } - } - event_loop_stopped_ = true; - vTaskDelete(nullptr); -#endif - } - int volume_ = 70; - std::atomic capture_started_us_{0}; - std::string stt_display_text_; - // 下行内容滚动窗口起始字符(0=从头);滚动迁移至 Ssd1306PresentationAdapter。 - // 本轮是否为终止回合(用户说“再见/拜拜”等):播报结束后不进入 follow-up。 - bool terminal_turn_ = false; - bool binding_turn_awaiting_tts_completion_ = false; - // 绑定成功/失败等终态页面的独立显示租约;只由事件循环读写。 - bool binding_terminal_display_active_ = false; - bool binding_terminal_resume_listening_ = false; - voice::VoiceMood binding_terminal_mood_ = voice::VoiceMood::kNeutral; - std::string binding_terminal_status_text_; - std::string binding_terminal_content_text_; - int64_t binding_terminal_until_us_ = 0; - // 最近唤醒词与其发生时刻(抑制唤醒词被服务端回传为 STT)。 - std::string last_wake_word_; - int64_t last_wake_at_ = 0; - int64_t wake_ack_requested_at_us_ = 0; - int64_t wake_ack_tts_started_at_us_ = 0; - // WakeAck 显示租约截止时刻(esp_timer_us):到期前下行栏显示“收到!”。 - int64_t wake_ack_until_us_ = 0; - // 音量 overlay 截止时刻(esp_timer_us):到期后恢复最新快照。 - int64_t volume_overlay_until_us_ = 0; - esp_timer_handle_t volume_overlay_timer_ = nullptr; - // 显示模型快照:会话阶段 → 可见状态的推导结果;revision 驱动增量重绘。 - voice::DisplaySnapshot snapshot_; - /** 临时 overlay 覆盖前的业务快照;到期后由事件循环恢复。 */ - voice::DisplaySnapshot overlay_base_snapshot_; - bool overlay_active_ = false; - uint64_t last_rendered_revision_ = 0; - // 构建期选定的平台装配(显示语义提交目标)。 - PlatformAssembly* assembly_ = nullptr; - esp_timer_handle_t listen_timer_ = nullptr; -#else - ScaffoldAudioInput audio_input_; - ScaffoldAudioOutput audio_output_; -#endif - // 仅完成依赖装配,现有事件循环尚未迁移到该路径。 - application::InteractionOrchestrator interaction_orchestrator_; - runtime_esp::EspInteractionTaskHost interaction_task_host_{interaction_orchestrator_}; - voice::VoiceInteractionController interaction_; - std::unique_ptr provider_; - std::unique_ptr session_; - - public: - Status RequestInterrupt() { - if (!session_) return Status::Error(ErrorCode::kUnavailable, "设备运行时尚未启动"); -#ifdef ESP_PLATFORM - EnqueueEvent(voice::VoiceInteractionEvent::kInterruptRequested); - return Status::Ok(); // 事件已投递,状态迁移由事件循环执行。 -#else - return Status::Error(ErrorCode::kUnavailable, "板端打断仅支持 ESP 平台"); -#endif - } -}; - -} // namespace - -Runtime& Instance() { - static Runtime runtime; - return runtime; -} -Status Start(PlatformAssembly& assembly) { return Instance().Start(assembly); } +Status Start(PlatformAssembly& assembly) { return runtime_esp::Start(assembly); } -Status RequestInterrupt() { return Instance().RequestInterrupt(); } +Status RequestInterrupt() { return runtime_esp::RequestInterrupt(); } } // namespace voicelife::runtime diff --git a/components/voicelife_runtime_esp/CMakeLists.txt b/components/voicelife_runtime_esp/CMakeLists.txt index b15413f2..e01a06b7 100644 --- a/components/voicelife_runtime_esp/CMakeLists.txt +++ b/components/voicelife_runtime_esp/CMakeLists.txt @@ -1,6 +1,20 @@ idf_component_register( - SRCS "src/esp_interaction_task_host.cc" + SRCS "src/esp_interaction_task_host.cc" "src/esp_runtime.cc" "src/esp_runtime_workers.cc" + "src/esp_runtime_board.cc" "src/esp_runtime_interaction.cc" "src/esp_runtime_event_loop.cc" + "src/bootstrap/storage_bootstrap.cc" + "src/im_runtime_bootstrap.cc" "src/linx_mcp_bridge.cc" "src/linx_ota_bootstrap.cc" + "src/wifi_provisioning.cc" "src/wifi_provisioning_esp.cc" "src/im_binding_mcp_tools.cc" + "src/im_binding_presentation.cc" INCLUDE_DIRS "include" - REQUIRES voicelife_application - PRIV_REQUIRES voicelife_mcp freertos + PRIV_INCLUDE_DIRS "../voicelife_runtime/include" "src" + REQUIRES voicelife_application voicelife_contracts + PRIV_REQUIRES voicelife_mcp voicelife_voice voicelife_linx voicelife_linx_esp voicelife_audio_esp + voicelife_display_esp voicelife_schedule voicelife_im voicelife_storage_fatfs + voicelife_storage_sqlite nvs_flash nvs_sec_provider esp_timer esp_http_client esp-tls esp_wifi + esp_netif lwip esp_event esp_http_server spi_flash esp_partition esp_psram esp_app_format + esp_driver_gpio esp_driver_usb_serial_jtag led_strip freertos ) + +if(NOT CONFIG_VOICELIFE_STORAGE_FATFS OR NOT CONFIG_VOICELIFE_STORAGE_SQLITE) + message(FATAL_ERROR "Runtime 日程持久化必须同时启用 VOICELIFE_STORAGE_FATFS 和 VOICELIFE_STORAGE_SQLITE") +endif() diff --git a/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h b/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h index 55ea67b7..4f47e2c5 100644 --- a/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h +++ b/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_interaction_task_host.h @@ -7,23 +7,24 @@ namespace voicelife::runtime_esp { /** * @brief ESP 侧交互任务的窄适配器。 * - * 本骨架只建立 Runtime Adapter 到应用服务的调用路径,未创建 FreeRTOS task, - * 也未接管既有 Runtime 事件循环。后续迁移只能从该适配器进入。 + * 它只负责将既有 FreeRTOS 事件循环归一化的事件送到应用服务;任务、队列和 + * 定时器的所有权仍在 Runtime ESP Adapter。 */ class EspInteractionTaskHost { public: /** @brief 创建使用指定应用服务的 ESP 交互任务宿主。 @param orchestrator 平台无关的交互编排器。 */ - explicit EspInteractionTaskHost(const application::InteractionOrchestrator& orchestrator); + explicit EspInteractionTaskHost(application::InteractionOrchestrator& orchestrator); /** * @brief 将 ESP 侧已归一化的事件交给平台无关的编排器。 * @param event 已归一化的交互事件。 * @param actions 用于接收编排动作的 Runtime Adapter 端口。 + * @return 事件编排和动作投影结果。 */ - void Submit(application::InteractionEvent event, application::InteractionActionSink& actions) const; + Status Submit(application::InteractionEvent event, application::InteractionActionSink& actions); private: - const application::InteractionOrchestrator& orchestrator_; + application::InteractionOrchestrator& orchestrator_; }; } // namespace voicelife::runtime_esp diff --git a/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_runtime.h b/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_runtime.h new file mode 100644 index 00000000..6befb7a2 --- /dev/null +++ b/components/voicelife_runtime_esp/include/voicelife/runtime_esp/esp_runtime.h @@ -0,0 +1,25 @@ +#pragma once + +#include "voicelife/contracts/status.h" + +namespace voicelife::runtime { +/** @brief Runtime 组合根公开的平台装配前置声明。 */ +class PlatformAssembly; +} // namespace voicelife::runtime + +namespace voicelife::runtime_esp { + +/** + * @brief 启动 ESP Runtime 适配器及其任务、队列和定时器。 + * @param assembly 构建期选定的平台装配。 + * @return Runtime 启动结果。 + */ +Status Start(runtime::PlatformAssembly& assembly); + +/** + * @brief 向 ESP Runtime 适配器投递取消当前语音回合的请求。 + * @return 请求投递结果。 + */ +Status RequestInterrupt(); + +} // namespace voicelife::runtime_esp diff --git a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc b/components/voicelife_runtime_esp/src/bootstrap/storage_bootstrap.cc similarity index 100% rename from components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc rename to components/voicelife_runtime_esp/src/bootstrap/storage_bootstrap.cc diff --git a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.h b/components/voicelife_runtime_esp/src/bootstrap/storage_bootstrap.h similarity index 100% rename from components/voicelife_runtime/src/bootstrap/storage_bootstrap.h rename to components/voicelife_runtime_esp/src/bootstrap/storage_bootstrap.h diff --git a/components/voicelife_runtime_esp/src/esp_interaction_task_host.cc b/components/voicelife_runtime_esp/src/esp_interaction_task_host.cc index 4b366d00..e228a7a6 100644 --- a/components/voicelife_runtime_esp/src/esp_interaction_task_host.cc +++ b/components/voicelife_runtime_esp/src/esp_interaction_task_host.cc @@ -4,13 +4,13 @@ namespace voicelife::runtime_esp { -EspInteractionTaskHost::EspInteractionTaskHost(const application::InteractionOrchestrator& orchestrator) +EspInteractionTaskHost::EspInteractionTaskHost(application::InteractionOrchestrator& orchestrator) : orchestrator_(orchestrator) {} -void EspInteractionTaskHost::Submit(application::InteractionEvent event, - application::InteractionActionSink& actions) const { +Status EspInteractionTaskHost::Submit(application::InteractionEvent event, + application::InteractionActionSink& actions) { static_assert(configMAX_PRIORITIES > 0, "FreeRTOS task priorities must be configured"); - orchestrator_.Handle(event, actions); + return orchestrator_.Handle(event, actions); } } // namespace voicelife::runtime_esp diff --git a/components/voicelife_runtime_esp/src/esp_runtime.cc b/components/voicelife_runtime_esp/src/esp_runtime.cc new file mode 100644 index 00000000..15962975 --- /dev/null +++ b/components/voicelife_runtime_esp/src/esp_runtime.cc @@ -0,0 +1,269 @@ +#include "esp_runtime_internal.h" + +#ifdef ESP_PLATFORM +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "im_binding_mcp_tools.h" +#include "linx_mcp_bridge.h" +#include "linx_ota_bootstrap.h" +#include "mcp_worker_policy.h" +#include "nvs.h" +#include "nvs_flash.h" +#include "voicelife/mcp/schedule_mcp_tools.h" + +namespace voicelife::runtime { +namespace { +#if CONFIG_NVS_ENCRYPTION +Result ReadNvsString(nvs_handle_t handle, const char* key) { + size_t required = 0; + esp_err_t error = nvs_get_str(handle, key, nullptr, &required); + if (error != ESP_OK || required <= 1) { + return Result::Failure(ErrorCode::kNotFound, std::string("缺少 Linx NVS 配置: ") + key); + } + std::string value(required, '\0'); + error = nvs_get_str(handle, key, value.data(), &required); + if (error != ESP_OK) { + return Result::Failure(ErrorCode::kUnavailable, "读取 Linx NVS 配置失败"); + } + value.resize(required > 0 ? required - 1 : 0); + if (value.empty()) { + return Result::Failure(ErrorCode::kInvalidArgument, std::string("Linx NVS 配置为空: ") + key); + } + return Result::Success(std::move(value)); +} +#endif +} // namespace + +Result NvsSecretResolver::Resolve(std::string_view reference) { +#if !CONFIG_NVS_ENCRYPTION + (void)reference; + return Result::Failure(ErrorCode::kUnavailable, "Linx token 解析需要启用 NVS encryption"); +#else + constexpr std::string_view prefix = "nvs://"; + if (reference.rfind(prefix, 0) != 0) { + return Result::Failure(ErrorCode::kInvalidArgument, "Linx token 引用必须使用 nvs://"); + } + const std::string path(reference.substr(prefix.size())); + const auto separator = path.find('/'); + if (separator == std::string::npos || separator == 0 || separator + 1 >= path.size()) { + return Result::Failure(ErrorCode::kInvalidArgument, "Linx token 引用格式无效"); + } + nvs_handle_t handle = 0; + const esp_err_t open_error = + nvs_open_from_partition(LinxSecretPartitionLabel(), path.substr(0, separator).c_str(), NVS_READONLY, &handle); + if (open_error != ESP_OK) { + return Result::Failure(ErrorCode::kNotFound, "Linx token NVS 命名空间不可用"); + } + auto result = ReadNvsString(handle, path.substr(separator + 1).c_str()); + nvs_close(handle); + return result; +#endif +} + +#endif + +Result ScaffoldSpeechProvider::audio_formats() const { + voice::VoiceAudioFormats fmt; + fmt.capture = voice::AudioFormat{}; + fmt.playback = voice::AudioFormat{}; + return Result::Success(fmt); +} +/** @brief 构造运行时并将日程服务绑定到持久化仓储。 */ +Runtime::Runtime() +#ifdef ESP_PLATFORM + : schedule_service_(storage_.GetScheduleRepository()), + schedule_operation_service_(storage_.GetScheduleOperationRepository()), + schedule_rule_service_(storage_.GetScheduleRuleRepository(), storage_.GetScheduleExceptionRepository(), + storage_.GetScheduleRepository()) +#endif +{ + auto& registry = voice::SpeechProviderRegistry::Instance(); +#ifdef ESP_PLATFORM + init_status_ = mcp::RegisterScheduleMcpTools(mcp_server_, schedule_service_, schedule_rule_service_); + if (init_status_.ok()) { + // MCP worker 只产生绑定结果;轮询与 OLED/TTS 均由各自受控任务处理。 + init_status_ = + RegisterImBindingMcpTools(mcp_server_, binding_use_case_, [this](const im::BindingResult& result) { + EnqueueBindingResult(result); + if (result.state == im::BindingState::kPending) StartBindingPolling(result.generation); + }); + } + if (init_status_.ok()) { + ESP_LOGI(kTag, + "MCP_TOOLS_READY count=5 names=schedule.create,schedule.query,schedule.update,schedule.delete," + "im.binding.start"); + } + registry.Register("xrobot-websocket", linx::LinxSpeechProviderAdapter::DefaultCapabilities(), [this]() { + return std::make_unique( + *linx_transport_, linx_codec_, linx_config_, linx::LinxSpeechProviderAdapter::DefaultCapabilities(), + [this](std::string_view payload, std::string_view session_id) { + return HandleMcpRequest(payload, session_id); + }); + }); +#endif + registry.Register("scaffold", voice::CapabilityProfile{"scaffold", {"streaming-asr", "tts"}}, + []() { return std::make_unique(); }); +} + +Status Runtime::Start(PlatformAssembly& assembly) { + assembly_ = &assembly; + const auto fail_startup = [this](Status status) { +#ifdef ESP_PLATFORM + StopMcpWorker(); + StopEventLoop(); +#endif + return status; + }; + auto& registry = voice::SpeechProviderRegistry::Instance(); + if (!init_status_.ok()) return init_status_; + const Status storage_status = storage_.Start(); + if (!storage_status.ok()) return storage_status; +#ifdef ESP_PLATFORM + // 立创实战派 ESP32-S3 板载 WS2812 灯珠接 GPIO48(小智 BUILTIN_LED_GPIO)。 + // 主 NVS 分区初始化(Wi-Fi 驱动/凭据等依赖;linx_secrets 为加密分区另行初始化)。 + { + esp_err_t nvs_error = nvs_flash_init(); + if (nvs_error == ESP_ERR_NVS_NO_FREE_PAGES || nvs_error == ESP_ERR_NVS_NEW_VERSION_FOUND) { + (void)nvs_flash_erase(); + nvs_error = nvs_flash_init(); + } + if (nvs_error != ESP_OK) { + ESP_LOGE(kTag, "STARTUP_ERROR stage=nvs_flash_init code=%d", static_cast(nvs_error)); + return Status::Error(ErrorCode::kInternal, "主 NVS 初始化失败"); + } + } + // 板级 LED 初始化(板型专属,Assembly 持有)。 + assembly_->InitializeBoardLeds(); + if (const Status display_status = assembly_->Start(); !display_status.ok()) { + ESP_LOGE(kTag, "STARTUP_ERROR stage=display_start code=%d msg=%s", static_cast(display_status.code), + display_status.message.c_str()); + return display_status; + } + // 显示启动后立即启动唯一的交互/显示语义写者。此后的启动、网络、音量 + // 和会话事件均只投递到该循环,不允许 Runtime 直接 Render。 + { + std::lock_guard lock(event_mutex_); + event_queue_.clear(); + event_loop_stop_ = false; + event_loop_stopped_ = false; + } + if (xTaskCreate(&Runtime::EventLoopTaskEntry, "voicelife_interaction", 8192, this, 5, &event_task_) != pdPASS) { + return Status::Error(ErrorCode::kInternal, "创建交互事件循环任务失败"); + } + if (const Status mcp_worker = StartMcpWorker(); !mcp_worker.ok()) { + return fail_startup(mcp_worker); + } + ShowDisplay(voice::VoiceMood::kConnecting, "联网", ""); + if (const Status secret_store = InitializeLinxSecretStore(); !secret_store.ok()) { + ESP_LOGW(kTag, "STARTUP_ERROR stage=secret_store code=%d", static_cast(secret_store.code)); + ShowDisplay(voice::VoiceMood::kSad, "错误", ""); + return fail_startup(secret_store); + } +#if CONFIG_VOICELIFE_IM_GATEWAY + // USB IM provisioning 不依赖 Wi-Fi;即使网络配置缺失并进入 SoftAP,也必须开放物理恢复窗口。 + if (!StartImProvisioningTask()) { + ESP_LOGW(kTag, "IM_PROVISION_TASK_FAILED=1"); + } +#endif + auto connection = + BootstrapLinxOtaConfig(assembly_->board_identity(), [this](std::string_view title, std::string_view detail) { + ShowDisplay(voice::VoiceMood::kConnecting, title, detail); + }); + // Bootstrap 无论是下发连接配置还是返回“待控制台激活”,均可能已经 + // 完成 STA 关联。由 Runtime 把受控网络事实写入快照,Renderer 只显示 + // 语义而不触碰 ESP Wi-Fi API。 + EnqueueNetworkState(LinxWifiStaConnected()); + if (!connection.ok() || !connection.value.has_value()) { + ESP_LOGW(kTag, "STARTUP_ERROR stage=linx_bootstrap code=%d", static_cast(connection.status.code)); + ShowDisplay(voice::VoiceMood::kSad, "错误", ""); + return fail_startup(connection.status); + } + ShowDisplay(voice::VoiceMood::kConnecting, "连接", ""); + linx_config_ = std::move(*connection.value); + // IM 的 SNTP、Gateway 探针和退避全部在独立任务中完成,语音启动路径不等待网络。 + StartImRuntime(); + auto result = registry.Create("xrobot-websocket", {}); +#else + auto result = registry.Create("scaffold", {}); +#endif + if (!result.ok() || !result.value.has_value()) { + ESP_LOGW(kTag, "STARTUP_ERROR stage=provider_create code=%d", static_cast(result.status.code)); + return fail_startup(Status::Error(ErrorCode::kInternal, "无法创建语音 Provider: " + result.status.message)); + } + provider_ = std::move(*result.value); + +#ifdef ESP_PLATFORM + // 音频端口由 Assembly 注入(业务 PCM 语义,不暴露 I2S/Codec)。 + assembly_->SetOutputVolume(static_cast(volume_)); + if (assembly_->uses_local_wake_detector()) { + assembly_->wake_gate().SetWakeSink([this](std::string_view wake_word) { QueueWakeWord(wake_word); }); + } + session_ = std::make_unique( + assembly_->wake_gate(), assembly_->audio_output(), *provider_, + [this](const voice::VoiceEvidence& evidence) { LogVoiceEvidence(evidence); }); + voice::VoiceSessionConfig config; + config.session_id = "voicelife-linx-session"; + config.provider_id = "xrobot-websocket"; + config.mode = voice::VoiceMode::kRealtime; + config.audio.codec = voice::AudioCodec::kPcmS16Le; + config.audio.sample_rate_hz = 16000; + config.audio.channels = 1; + config.audio.bits_per_sample = 16; + config.audio.frame_duration_ms = 20; +#else + session_ = std::make_unique(audio_input_, audio_output_, *provider_); + voice::VoiceSessionConfig config; + config.session_id = "scaffold-session"; + config.provider_id = "scaffold"; +#endif + const Status session_status = session_->Start(config); + if (!session_status.ok()) { + ESP_LOGW(kTag, "STARTUP_ERROR stage=session_start code=%d", static_cast(session_status.code)); + ShowDisplay(voice::VoiceMood::kSad, "错误", ""); + return fail_startup(session_status); + } + +#ifdef ESP_PLATFORM + if (wake_queue_ == nullptr) { + wake_queue_ = xQueueCreate(4, sizeof(BoardRequest)); + if (wake_queue_ == nullptr) return fail_startup(Status::Error(ErrorCode::kInternal, "创建唤醒队列失败")); + const BaseType_t task_status = + xTaskCreate(&Runtime::WakeTaskEntry, "voicelife_wake", 4096, this, 5, &wake_task_); + if (task_status != pdPASS) return fail_startup(Status::Error(ErrorCode::kInternal, "创建唤醒控制任务失败")); + } + EnqueueEvent(voice::VoiceInteractionEvent::kBootCompleted); + const Status input_status = + assembly_->StartBoardInput([this](BoardInputAction action) { EnqueueBoardInput(action); }); + if (!input_status.ok()) return fail_startup(input_status); +#if CONFIG_VOICELIFE_STATE_FLOW_TEST + if (const Status state_flow_status = StartStateFlowDiagnostic(); !state_flow_status.ok()) { + return fail_startup(state_flow_status); + } +#endif +#endif + return Status::Ok(); +} + +Status Runtime::RequestInterrupt() { + if (!session_) return Status::Error(ErrorCode::kUnavailable, "设备运行时尚未启动"); +#ifdef ESP_PLATFORM + EnqueueEvent(voice::VoiceInteractionEvent::kInterruptRequested); + return Status::Ok(); +#else + return Status::Error(ErrorCode::kUnavailable, "板端打断仅支持 ESP 平台"); +#endif +} + +Runtime& Instance() { + static Runtime runtime; + return runtime; +} + +Status StartEspImpl(PlatformAssembly& assembly) { return Instance().Start(assembly); } +Status RequestInterruptEspImpl() { return Instance().RequestInterrupt(); } +} // namespace voicelife::runtime + +namespace voicelife::runtime_esp { +Status Start(runtime::PlatformAssembly& assembly) { return runtime::StartEspImpl(assembly); } +Status RequestInterrupt() { return runtime::RequestInterruptEspImpl(); } +} // namespace voicelife::runtime_esp diff --git a/components/voicelife_runtime_esp/src/esp_runtime_board.cc b/components/voicelife_runtime_esp/src/esp_runtime_board.cc new file mode 100644 index 00000000..4daedaab --- /dev/null +++ b/components/voicelife_runtime_esp/src/esp_runtime_board.cc @@ -0,0 +1,391 @@ +#include "esp_runtime_internal.h" + +#ifdef ESP_PLATFORM +#include + +#include "esp_log.h" +#include "esp_timer.h" + +namespace voicelife::runtime { +void Runtime::EnqueueBoardInput(BoardInputAction action) { + InteractionEventItem item{}; + item.board_input = true; + item.board_action = action; + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +void Runtime::SetVolume(int volume) { + volume_ = std::clamp(volume, 0, 100); + if (assembly_ != nullptr) assembly_->SetOutputVolume(volume_); + // 音量通知 overlay:临时覆盖显示,1.5s 后恢复最新快照(不修改会话状态)。 + // 连续调音量只重置同一个计时器。 + char text[16] = {}; + std::snprintf(text, sizeof(text), "VOL:%d", volume_); + ShowOverlay(voice::VoiceMood::kIdle, "音量", text); + volume_overlay_until_us_ = esp_timer_get_time() + kVolumeOverlayUs; + if (volume_overlay_timer_ == nullptr) { + esp_timer_create_args_t args = {}; + args.callback = &VolumeOverlayEntry; + args.arg = this; + args.name = "voicelife_volume_overlay"; + (void)esp_timer_create(&args, &volume_overlay_timer_); + } + if (volume_overlay_timer_ != nullptr) { + (void)esp_timer_stop(volume_overlay_timer_); + (void)esp_timer_start_once(volume_overlay_timer_, kVolumeOverlayUs); + } +} + +void Runtime::QueueWakeWord(std::string_view wake_word) { + LogVoiceEvidence({.session_id = session_ ? session_->config().session_id : "", + .generation = session_ ? session_->generation() : 0, + .event = "wake_detected", + .detail = {}}); + // “别说了”要中止旧播报后只回复一次“收到!”,随即转入聆听;它不是 + // 静默中止,也不能被当作普通唤醒后让旧 TTS 继续播放。 + const auto event = wake_word == "别说了" ? voice::VoiceInteractionEvent::kInterruptAndAcknowledge + : voice::VoiceInteractionEvent::kWakeDetected; + EnqueueEvent(event, wake_word); +} + +void Runtime::QueueVoiceTurn(std::string_view wake_word) { + if (wake_queue_ == nullptr) return; + BoardRequest request{}; + request.kind = BoardRequestKind::kWakeWord; + const std::size_t size = + wake_word.size() < sizeof(request.wake_word) - 1 ? wake_word.size() : sizeof(request.wake_word) - 1; + std::memcpy(request.wake_word, wake_word.data(), size); + request.wake_word[size] = '\0'; + (void)xQueueSend(wake_queue_, &request, 0); +} + +void Runtime::QueueInterruptAndVoiceTurn(std::string_view wake_word) { + if (wake_queue_ == nullptr) return; + BoardRequest request{}; + request.kind = BoardRequestKind::kInterruptAndWakeWord; + const std::size_t size = + wake_word.size() < sizeof(request.wake_word) - 1 ? wake_word.size() : sizeof(request.wake_word) - 1; + std::memcpy(request.wake_word, wake_word.data(), size); + request.wake_word[size] = '\0'; + (void)xQueueSend(wake_queue_, &request, 0); +} + +void Runtime::QueueStandbyRecovery(bool settle_controller) { + if (wake_queue_ == nullptr) return; + BoardRequest recovery{}; + recovery.settle_controller = settle_controller; + (void)xQueueSend(wake_queue_, &recovery, 0); +} + +bool Runtime::QueueSystemSpeech(std::string_view text) { + if (wake_queue_ == nullptr || text.empty()) return false; + if (text.size() >= kBindingSystemSpeechCapacity) { + ESP_LOGE(kTag, "SYSTEM_SPEECH_TOO_LONG bytes=%u", static_cast(text.size())); + return false; + } + BoardRequest request{}; + request.kind = BoardRequestKind::kInterrupt; + std::memcpy(request.system_speech, text.data(), text.size()); + request.system_speech[text.size()] = '\0'; + if (xQueueSend(wake_queue_, &request, 0) != pdTRUE) { + ESP_LOGW(kTag, "SYSTEM_SPEECH_QUEUE_FULL=1"); + return false; + } + return true; +} + +// 下行长文本滚动由显示 Adapter 负责(Ssd1306PresentationAdapter)。 +// 音量 overlay 到期:递增 revision 触发 CommitSnapshot 恢复最新快照。 +void Runtime::VolumeOverlayEntry(void* context) { + auto* self = static_cast(context); + self->volume_overlay_until_us_ = 0; + self->overlay_expired_.store(true); // 只置标志;恢复由事件循环唯一执行。 +} + +// 聆听/最终 STT 超时: +// - kListening 超时(无有效输入):结束本轮回待机 +// - kFinalizing 超时(listen.stop 后 5s 无最终 STT):abort 结束服务端回合回待机 +void Runtime::ListenTimeoutEntry(void* context) { + auto* self = static_cast(context); + // Timer 回调不能读取或迁移交互状态;由事件循环串行决定超时路径。 + ESP_LOGI(kTag, "LISTEN_TIMEOUT_FIRED"); + self->EnqueueListenTimeout(); +} + +void Runtime::StartListenTimer(uint32_t timeout_ms) { + if (listen_timer_ == nullptr) { + esp_timer_create_args_t args = {}; + args.callback = &ListenTimeoutEntry; + args.arg = this; + args.name = "voicelife_listen_timeout"; + if (esp_timer_create(&args, &listen_timer_) != ESP_OK) { + listen_timer_ = nullptr; + return; + } + } + (void)esp_timer_stop(listen_timer_); + const esp_err_t start = esp_timer_start_once(listen_timer_, timeout_ms * 1000ULL); + if (start != ESP_OK) { + ESP_LOGW(kTag, "LISTEN_TIMEOUT_ARM_FAILED ms=%u err=%d", static_cast(timeout_ms), + static_cast(start)); + return; + } + ESP_LOGI(kTag, "LISTEN_TIMEOUT_ARMED ms=%u", static_cast(timeout_ms)); +} + +void Runtime::CancelListenTimer() { + if (listen_timer_ != nullptr) { + (void)esp_timer_stop(listen_timer_); + } +} + +void Runtime::QueueInterrupt() { + if (wake_queue_ == nullptr) return; + BoardRequest request{}; + request.kind = BoardRequestKind::kInterrupt; + (void)xQueueSend(wake_queue_, &request, 0); +} + +void Runtime::QueueCaptureStart() { + if (wake_queue_ == nullptr) return; + BoardRequest request{}; + request.kind = BoardRequestKind::kStartCapture; + (void)xQueueSend(wake_queue_, &request, 0); +} + +void Runtime::QueueCaptureStop() { + if (wake_queue_ == nullptr) return; + BoardRequest request{}; + request.kind = BoardRequestKind::kStopCapture; + (void)xQueueSend(wake_queue_, &request, 0); +} + +void Runtime::QueueInterruptAndCapture() { + if (wake_queue_ == nullptr) return; + BoardRequest request{}; + request.kind = BoardRequestKind::kInterruptAndStartCapture; + (void)xQueueSend(wake_queue_, &request, 0); +} + +#if CONFIG_VOICELIFE_STATE_FLOW_TEST +Status Runtime::StartStateFlowDiagnostic() { + if (state_flow_task_ != nullptr) return Status::Ok(); + if (xTaskCreate(&Runtime::StateFlowTaskEntry, "voicelife_state_flow", 4096, this, 1, &state_flow_task_) != pdPASS) { + return Status::Error(ErrorCode::kInternal, "创建状态流诊断任务失败"); + } + ESP_LOGI(kTag, "STATE_FLOW_TEST_STARTED production_default=0"); + return Status::Ok(); +} + +void Runtime::StateFlowTaskEntry(void* context) { static_cast(context)->StateFlowTask(); } + +void Runtime::StateFlowEvent(uint32_t step, voice::VoiceInteractionEvent event) { + ESP_LOGI(kTag, "STATE_FLOW_ENQUEUE step=%u kind=interaction event=%d", static_cast(step), + static_cast(event)); + EnqueueEvent(event); +} + +void Runtime::StateFlowEvidence(uint32_t step, std::string_view event, std::string_view detail) { + ESP_LOGI(kTag, "STATE_FLOW_ENQUEUE step=%u kind=evidence event=%.*s detail_bytes=%u", static_cast(step), + static_cast(event.size()), event.data(), static_cast(detail.size())); + voice::VoiceEvidence evidence; + evidence.session_id = session_ ? session_->config().session_id : "state-flow"; + evidence.generation = session_ ? session_->generation() : 0; + evidence.event = std::string(event); + evidence.detail = std::string(detail); + EnqueueVoiceEvidence(evidence); +} + +void Runtime::StateFlowTask() { + // Test-only diagnostic. It submits normal semantic inputs/evidence and + // never calls a renderer, PresentationPort, GPIO, or audio output. + vTaskDelay(pdMS_TO_TICKS(1500)); + uint32_t step = 1; + StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportDisconnected); + vTaskDelay(pdMS_TO_TICKS(350)); + StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportConnected); + vTaskDelay(pdMS_TO_TICKS(350)); + StateFlowEvent(step++, voice::VoiceInteractionEvent::kPressDown); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "capture_started"); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "stt_text_received", "请在明天 09:30 创建日程: Review #42, room A-3."); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "mcp_tool_started"); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "mcp_tool_result", "event=Review #42; status=created"); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "tts_started"); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "tts_sentence_started", "已创建日程。明天 09:30 在 A-3 开会。"); + vTaskDelay(pdMS_TO_TICKS(150)); + // A state-flow build must not invent a local TTS completion when no + // real PCM turn was opened. Exercise the production cancellation path + // instead: Runtime asks VoiceSession to interrupt and only its real + // completion restores standby. + StateFlowEvent(step++, voice::VoiceInteractionEvent::kInterruptRequested); + vTaskDelay(pdMS_TO_TICKS(500)); + for (uint32_t cycle = 0; cycle < 20; ++cycle) { + StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportDisconnected); + vTaskDelay(pdMS_TO_TICKS(90)); + StateFlowEvent(step++, voice::VoiceInteractionEvent::kTransportConnected); + vTaskDelay(pdMS_TO_TICKS(90)); + } + StateFlowEvent(step++, voice::VoiceInteractionEvent::kPressDown); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "capture_started"); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvent(step++, voice::VoiceInteractionEvent::kFailure); + vTaskDelay(pdMS_TO_TICKS(300)); + StateFlowEvent(step++, voice::VoiceInteractionEvent::kStandbyReady); + vTaskDelay(pdMS_TO_TICKS(300)); + StateFlowEvent(step++, voice::VoiceInteractionEvent::kPressDown); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvidence(step++, "capture_started"); + vTaskDelay(pdMS_TO_TICKS(150)); + StateFlowEvent(step++, voice::VoiceInteractionEvent::kInterruptRequested); + vTaskDelay(pdMS_TO_TICKS(150)); + // kInterruptRequested reaches VoiceSession, whose real interrupted + // evidence restores standby through the event loop. Do not inject a + // second completion after that recovery: it is necessarily stale and + // would make this diagnostic report a false ordering rejection. + ESP_LOGI(kTag, "STATE_FLOW_TEST_FINISHED steps=%u", static_cast(step - 1)); + state_flow_task_ = nullptr; + vTaskDelete(nullptr); +} +#endif + +void Runtime::RestoreStandby(const BoardRequest& request) { + if (assembly_ == nullptr) return; + const Status stop_status = assembly_->wake_gate().StopCapture(); + if (!stop_status.ok()) { + ESP_LOGW(kTag, "本地待机恢复停止上行失败: %s", stop_status.message.c_str()); + (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); + return; + } + const Status standby_status = assembly_->wake_gate().StartStandby(); + if (!standby_status.ok()) { + ESP_LOGW(kTag, "本地待机恢复失败: %s", standby_status.message.c_str()); + (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); + return; + } + LogVoiceEvidence({.session_id = session_ ? session_->config().session_id : "", + .generation = session_ ? session_->generation() : 0, + .event = "standby_ready", + .detail = {}}); + // 显式派发 kStandbyReady:Controller 从 Error/kFinalizing 回 Standby, + // 避免 RestoreStandby 直接写快照造成控制器仍停 Error 的假待机 + // (WAKE_REARM atomic=0)。Controller 回 Standby 后由状态机动作 + // 统一提交时间快照。 + // 事件化:状态迁移由事件循环唯一执行,拒绝日志在事件循环统一输出。 + if (request.settle_controller) { + EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); + } +} + +void Runtime::WakeTaskEntry(void* context) { static_cast(context)->WakeTask(); } + +void Runtime::WakeTask() { + BoardRequest request{}; + while (true) { + if (xQueueReceive(wake_queue_, &request, portMAX_DELAY) != pdTRUE) continue; + if (request.kind == BoardRequestKind::kRestoreStandby) { + RestoreStandby(request); + continue; + } + if (request.kind == BoardRequestKind::kInterruptAndWakeWord) { + if (!session_ || !provider_) continue; + const Status acknowledge = session_->InterruptAndNotifyLocalWakeWord(request.wake_word, "收到!"); + if (!acknowledge.ok()) { + ESP_LOGW(kTag, "打断确认请求失败: %s", acknowledge.message.c_str()); + QueueStandbyRecovery(); + } + continue; + } + if (request.kind == BoardRequestKind::kInterrupt) { + if (!session_) continue; + const Status interrupt = session_->Interrupt(); + if (request.system_speech[0] != '\0') { + const Status speak = interrupt.ok() ? session_->Speak(request.system_speech) : interrupt; + if (!speak.ok()) { + ESP_LOGW(kTag, "系统播报请求失败: %s", speak.message.c_str()); + QueueStandbyRecovery(); + } + continue; + } + if (interrupt.ok()) { + if (interaction_orchestrator_.state() == voice::VoiceInteractionState::kInterrupting) { + (void)EnqueueEvent(voice::VoiceInteractionEvent::kInterruptCompleted); + } else { + QueueStandbyRecovery(); + } + } else { + ESP_LOGW(kTag, "板端打断失败: %s", interrupt.message.c_str()); + QueueStandbyRecovery(); + } + continue; + } + if (request.kind == BoardRequestKind::kStartCapture) { + // 开麦前等待播放排空(I2S 实际播完,而非队列空),避免把残留 + // TTS 重新采进 follow-up(NoAudioCodec 无 AEC)。 + if (assembly_ != nullptr) { + for (int i = 0; i < 30 && !assembly_->audio_output().IsIdle(); ++i) { + vTaskDelay(pdMS_TO_TICKS(50)); + } + } + const Status capture = + session_ ? session_->BeginCapture() : Status::Error(ErrorCode::kUnavailable, "语音会话尚未启动"); + if (!capture.ok()) { + ESP_LOGW(kTag, "板级按键开始采集失败: %s", capture.message.c_str()); + // 事务式启动失败:回待机(kStandbyReady),不显示"出错了/牛牛走了"。 + (void)EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); + } + continue; + } + if (request.kind == BoardRequestKind::kStopCapture) { + const Status stop = + session_ ? session_->EndCapture() : Status::Error(ErrorCode::kUnavailable, "语音会话尚未启动"); + if (!stop.ok()) { + ESP_LOGW(kTag, "板级按键结束采集失败: %s", stop.message.c_str()); + (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); + } else { + // 仅当已离开 kFinalizing(VAD 端点后等待最终 STT 中)才恢复待机: + // kFinalizing 表示本轮还在等最终 STT/TTS,不能提前回待机。 + // 其余(聆听正常结束、超时、按键停止)恢复待机。 + if (interaction_orchestrator_.state() != voice::VoiceInteractionState::kFinalizing) { + QueueStandbyRecovery(); + } + } + continue; + } + if (request.kind == BoardRequestKind::kInterruptAndStartCapture) { + if (!session_) continue; + const Status interrupt = session_->Interrupt(); + const Status capture = interrupt.ok() ? session_->BeginCapture() : interrupt; + if (!capture.ok()) { + ESP_LOGW(kTag, "板级打断后开始采集失败: %s", capture.message.c_str()); + // 打断后启动失败:回待机,不显示"出错了/牛牛走了"。 + (void)EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); + } + continue; + } + if (!session_ || !provider_) continue; + // Linx 官方协议支持 listen.detect.text_response:服务端真实合成 + // “收到!”并下发协商 PCM,tts.stop 后 Controller 才开始聆听。 + const Status acknowledge = session_->NotifyLocalWakeWord(request.wake_word, "收到!"); + if (!acknowledge.ok()) { + ESP_LOGW(kTag, "唤醒确认请求失败: %s", acknowledge.message.c_str()); + // 唤醒启动失败:回待机,不显示"出错了/牛牛走了"。 + (void)EnqueueEvent(voice::VoiceInteractionEvent::kStandbyReady); + } + } +} + +// 显示模型:由会话阶段推导可见状态,仅在 revision 变化时提交渲染器。 +} // namespace voicelife::runtime +#endif diff --git a/components/voicelife_runtime_esp/src/esp_runtime_event_loop.cc b/components/voicelife_runtime_esp/src/esp_runtime_event_loop.cc new file mode 100644 index 00000000..efa5ae02 --- /dev/null +++ b/components/voicelife_runtime_esp/src/esp_runtime_event_loop.cc @@ -0,0 +1,409 @@ +#include "esp_runtime_internal.h" + +#ifdef ESP_PLATFORM +#include "esp_log.h" +#include "esp_timer.h" +#include "linx_ota_bootstrap.h" + +namespace voicelife::runtime { +void Runtime::EnqueueEvent(voice::VoiceInteractionEvent event, std::string_view wake_word) { + InteractionEventItem item{}; + item.event = event; + item.wake_word = std::string(wake_word); + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) { + event_queue_.pop_front(); + } + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +/** @brief 投递纯显示刷新(TTS 文本等,事件循环内应用,不触发状态机)。 */ +void Runtime::EnqueueDisplayText(std::string detail) { + InteractionEventItem item{}; + item.display_only = true; + item.display_text = std::move(detail); + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) { + event_queue_.pop_front(); + } + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +/** @brief 投递启动/错误/overlay 等系统语义;不携带硬件资源或原始数据。 */ +void Runtime::EnqueueDisplayUpdate(voice::VoiceMood mood, std::string_view status, std::string_view content, + bool overlay) { + InteractionEventItem item{}; + item.display_update = true; + item.display_overlay = overlay; + item.display_mood = mood; + item.display_status = std::string(status); + item.display_content = std::string(content); + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +void Runtime::EnqueueBindingResult(const im::BindingResult& result) { + InteractionEventItem item{}; + item.binding_result = true; + item.binding = result; + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +void Runtime::EnqueueBindingReset(uint64_t generation) { + InteractionEventItem item{}; + item.binding_reset = true; + item.binding_generation = generation; + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +void Runtime::CancelBindingTerminalDisplay() { + binding_terminal_display_active_ = false; + binding_terminal_resume_listening_ = false; + binding_terminal_until_us_ = 0; + binding_terminal_status_text_.clear(); + binding_terminal_content_text_.clear(); +} + +void Runtime::ClearExpiredBindingTerminalDisplay() { + if (!binding_terminal_display_active_ || binding_terminal_until_us_ == 0 || + esp_timer_get_time() < binding_terminal_until_us_) { + return; + } + const bool resume_listening = binding_terminal_resume_listening_; + CancelBindingTerminalDisplay(); + deferred_binding_speech_.clear(); + if (interaction_orchestrator_.state() != voice::VoiceInteractionState::kStandby) return; + if (resume_listening) { + ESP_LOGI(kTag, "IM_BINDING_TERMINAL_DISPLAY_EXPIRED=1 next=listening"); + snapshot_.content_text.clear(); + snapshot_.role = voice::VoiceContentRole::kNone; + (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kToggleChat); + return; + } + snapshot_.phase = voice::VoiceInteractionState::kStandby; + snapshot_.mood = voice::VoiceMood::kIdle; + snapshot_.status_text = CurrentStandbyStatusText(); + snapshot_.content_text.clear(); + snapshot_.role = voice::VoiceContentRole::kNone; + ++snapshot_.revision; + overlay_active_ = false; + CommitSnapshot(); + ESP_LOGI(kTag, "IM_BINDING_TERMINAL_DISPLAY_EXPIRED=1"); +} + +void Runtime::CommitBindingPresentation(const BindingPresentation& presentation) { + snapshot_.mood = presentation.content_text == "绑定成功" ? voice::VoiceMood::kHappy : voice::VoiceMood::kNeutral; + snapshot_.status_text = presentation.status_text; + snapshot_.content_text = presentation.content_text; + snapshot_.role = voice::VoiceContentRole::kSystem; + ++snapshot_.revision; + overlay_active_ = false; + CommitSnapshot(); + if (presentation.display_duration_ms > 0) { + binding_terminal_display_active_ = true; + binding_terminal_mood_ = snapshot_.mood; + binding_terminal_status_text_ = presentation.status_text; + binding_terminal_content_text_ = presentation.content_text; + binding_terminal_resume_listening_ = presentation.resume_listening; + binding_terminal_until_us_ = + esp_timer_get_time() + static_cast(presentation.display_duration_ms) * 1000; + } else { + CancelBindingTerminalDisplay(); + } +} + +void Runtime::QueueDeferredBindingSpeechIfStandby() { + if (interaction_orchestrator_.state() != voice::VoiceInteractionState::kStandby) return; + if (deferred_binding_presentation_.has_value()) { + CommitBindingPresentation(*deferred_binding_presentation_); + deferred_binding_presentation_.reset(); + } + if (deferred_binding_speech_.empty()) return; + std::string speech = std::move(deferred_binding_speech_); + deferred_binding_speech_.clear(); + if (!QueueSystemSpeech(speech)) deferred_binding_speech_ = std::move(speech); +} + +void Runtime::ProcessBindingResult(const im::BindingResult& result) { + // Bind() increments the generation before replacing client/config dependencies. + // A completed HTTP query from the prior origin can therefore never show success + // after reconfiguration or an explicit restart. + const uint64_t current_generation = binding_use_case_.generation(); + if (!IsCurrentBindingResult(result, current_generation)) { + ESP_LOGI(kTag, "IM_BINDING_STALE_RESULT=1 result_generation=%llu current_generation=%llu", + static_cast(result.generation), + static_cast(current_generation)); + return; + } + const BindingPresentation presentation = PresentBindingResult(result); + if (!presentation.keep_visible && !presentation.announce) return; + + if (ShouldEndVoiceTurnAfterBindingResult( + result, interaction_orchestrator_.state() != voice::VoiceInteractionState::kStandby)) { + binding_turn_awaiting_tts_completion_ = true; + } + + binding_display_active_ = presentation.keep_visible; + binding_display_generation_ = result.generation; + if (presentation.keep_visible) { + binding_status_text_ = presentation.status_text; + binding_content_text_ = presentation.content_text; + } else { + binding_status_text_.clear(); + binding_content_text_.clear(); + } + // 终态在普通对话中抵达时,将 OLED 与 TTS 作为一个结果延后到待机。 + // 这不会抢写用户正在看的 STT 或助手回复。 + if (!presentation.keep_visible && interaction_orchestrator_.state() != voice::VoiceInteractionState::kStandby) { + deferred_binding_presentation_ = presentation; + deferred_binding_speech_ = presentation.speech_text; + return; + } + + CommitBindingPresentation(presentation); + if (!presentation.announce) return; + if (interaction_orchestrator_.state() == voice::VoiceInteractionState::kStandby) { + if (!QueueSystemSpeech(presentation.speech_text)) deferred_binding_speech_ = presentation.speech_text; + } else { + // 活跃 MCP 回合的响应已携带 speak_text,由 Provider 播报一次。 + // 不再延迟本地重复播报;该播报结束后会直接回待机显示绑定码。 + } +} + +void Runtime::EnqueueVoiceEvidence(const voice::VoiceEvidence& evidence) { + InteractionEventItem item{}; + item.voice_evidence = true; + item.evidence = evidence; + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +void Runtime::EnqueueListenTimeout() { + InteractionEventItem item{}; + item.listen_timeout = true; + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +void Runtime::EnqueueNetworkState(bool connected) { + InteractionEventItem item{}; + item.network_update = true; + item.network_connected = connected; + { + std::lock_guard lock(event_mutex_); + if (event_queue_.size() >= kEventQueueCapacity) event_queue_.pop_front(); + event_queue_.push_back(std::move(item)); + } + event_cv_.notify_one(); +} + +/** @brief 事件循环任务入口(唯一调用 HandleInteractionEvent 的线程)。 */ +void Runtime::EventLoopTaskEntry(void* arg) { static_cast(arg)->EventLoopLoop(); } + +/** @brief 事件循环:消费事件 -> 状态迁移 -> 快照 -> 显示提交。 */ +void Runtime::EventLoopLoop() { +#ifdef ESP_PLATFORM + while (true) { + InteractionEventItem item; + { + std::unique_lock lock(event_mutex_); + event_cv_.wait_for(lock, std::chrono::milliseconds(200), + [this] { return event_loop_stop_ || !event_queue_.empty(); }); + if (event_loop_stop_ && event_queue_.empty()) { + break; + } + if (event_queue_.empty()) { + // 超时轮询:处理短暂显示的到期刷新(不依赖 timer 直接提交)。 + ClearExpiredWakeAck(); + ClearExpiredBindingTerminalDisplay(); + if (overlay_expired_.exchange(false)) { + if (overlay_active_) { + snapshot_ = overlay_base_snapshot_; + ++snapshot_.revision; + overlay_active_ = false; + CommitSnapshot(); + } + } + continue; + } + item = std::move(event_queue_.front()); + event_queue_.pop_front(); + } + // provider_error 等事件持续占满队列时,终态租约仍必须按时收口。 + ClearExpiredBindingTerminalDisplay(); + if (item.display_only) { + // 纯显示刷新:仅当控制器处于 kSpeaking 时应用(迟到的 TTS 丢弃)。 + if (interaction_orchestrator_.state() == voice::VoiceInteractionState::kSpeaking && + !item.display_text.empty()) { + snapshot_.content_text = item.display_text; + snapshot_.role = voice::VoiceContentRole::kAssistant; + snapshot_.status_text = "说话中"; + snapshot_.mood = voice::VoiceMood::kSpeaking; + ++snapshot_.revision; + CommitSnapshot(); + } + continue; + } + if (item.network_update) { + snapshot_.network_connected = item.network_connected; + continue; + } + if (item.board_input) { + switch (item.board_action) { + case BoardInputAction::kToggleChat: + (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kToggleChat); + break; + case BoardInputAction::kPressDown: + (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kPressDown); + break; + case BoardInputAction::kPressUp: + (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kPressUp); + break; + case BoardInputAction::kVolumeUp: + SetVolume(std::min(volume_ + 10, 100)); + break; + case BoardInputAction::kVolumeDown: + SetVolume(std::max(volume_ - 10, 0)); + break; + case BoardInputAction::kVolumeMaximum: + SetVolume(100); + break; + case BoardInputAction::kVolumeMute: + SetVolume(0); + break; + case BoardInputAction::kStartWifiProvisioning: { + ShowDisplay(voice::VoiceMood::kConnecting, "配网", "正在开启热点"); + const Status requested = RequestLinxWifiProvisioning(); + if (!requested.ok()) ShowDisplay(voice::VoiceMood::kSad, "配网失败", ""); + break; + } + } + continue; + } + if (item.voice_evidence) { + ProcessVoiceEvidence(item.evidence); + continue; + } + if (item.binding_result) { + ProcessBindingResult(item.binding); + continue; + } + if (item.binding_reset) { + if (item.binding_generation == binding_use_case_.generation()) { + binding_display_active_ = false; + binding_display_generation_ = item.binding_generation; + binding_status_text_.clear(); + binding_content_text_.clear(); + deferred_binding_presentation_.reset(); + deferred_binding_speech_.clear(); + binding_turn_awaiting_tts_completion_ = false; + CancelBindingTerminalDisplay(); + // 重绑/重启策略不允许旧 origin 的绑定码或成功提示留在屏幕上。 + // 非空闲回合会由紧随其后的交互事件接管显示;空闲时立即收口。 + if (interaction_orchestrator_.state() == voice::VoiceInteractionState::kStandby) { + snapshot_.mood = voice::VoiceMood::kIdle; + snapshot_.status_text = CurrentStandbyStatusText(); + snapshot_.content_text.clear(); + snapshot_.role = voice::VoiceContentRole::kNone; + ++snapshot_.revision; + overlay_active_ = false; + CommitSnapshot(); + } + } + continue; + } + if (item.listen_timeout) { + if (interaction_orchestrator_.state() == voice::VoiceInteractionState::kListening) { + // 实机麦克风底噪可能让本地 VAD 未能识别静音端点,但此前 + // 已采集的语音仍必须以 listen.stop 交给服务端完成最终 STT。 + // 直接 abort 会无条件丢弃该回合,表现为“收到后不再回应”。 + ESP_LOGI(kTag, "LISTEN_TIMEOUT transition=listening->finalizing"); + (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kEndpointDetected); + StartListenTimer(kFinalSttTimeoutMs); + } else if (interaction_orchestrator_.state() == voice::VoiceInteractionState::kFinalizing) { + ESP_LOGI(kTag, "FINALIZE_TIMEOUT transition=finalizing->standby"); + if (session_) (void)session_->Interrupt(); + (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kFinalizationTimedOut); + } + continue; + } + if (item.display_update) { + if (item.display_overlay) { + overlay_base_snapshot_ = snapshot_; + overlay_active_ = true; + } else { + overlay_active_ = false; + } + snapshot_.mood = item.display_mood; + snapshot_.status_text = std::move(item.display_status); + snapshot_.content_text = std::move(item.display_content); + snapshot_.role = voice::VoiceContentRole::kSystem; + ++snapshot_.revision; + CommitSnapshot(); + ESP_LOGI(kTag, "DISPLAY_SEMANTIC_UPDATE overlay=%d mood=%d generation=%llu revision=%llu", + item.display_overlay ? 1 : 0, static_cast(snapshot_.mood), + static_cast(snapshot_.generation), + static_cast(snapshot_.revision)); + continue; + } + if (item.event == voice::VoiceInteractionEvent::kWakeDetected || + item.event == voice::VoiceInteractionEvent::kInterruptAndAcknowledge) { + // 唤醒前置(唯一状态写者内):显示租约;声音由 Linx TTS 的 + // text_response 产生,绝不在 Runtime 直接推裸 PCM。 + last_wake_word_ = item.wake_word; + last_wake_at_ = esp_timer_get_time(); + wake_ack_requested_at_us_ = last_wake_at_; + wake_ack_tts_started_at_us_ = 0; + wake_ack_until_us_ = esp_timer_get_time() + kWakeAckDisplayUs; + } + const Status wake_status = HandleInteractionEvent(item.event, item.wake_word); + if (item.event != voice::VoiceInteractionEvent::kWakeDetected && !wake_status.ok()) { + ESP_LOGW(kTag, "INTERACTION_REJECTED event=%d state=%d err=%s", static_cast(item.event), + static_cast(interaction_orchestrator_.state()), wake_status.message.c_str()); + } + if ((item.event == voice::VoiceInteractionEvent::kWakeDetected || + item.event == voice::VoiceInteractionEvent::kInterruptAndAcknowledge || + item.event == voice::VoiceInteractionEvent::kInterruptRequested) && + !wake_status.ok()) { + // 非法本地命令(例如待机时“别说了”)不得让检测器停死。 + ESP_LOGW(kTag, "LOCAL_COMMAND_REJECTED state=%d err=%s", + static_cast(interaction_orchestrator_.state()), wake_status.message.c_str()); + if (assembly_->uses_local_wake_detector()) { + (void)assembly_->wake_gate().StartStandby(); + } + } + } + event_loop_stopped_ = true; + vTaskDelete(nullptr); +#endif +} +} // namespace voicelife::runtime +#endif diff --git a/components/voicelife_runtime_esp/src/esp_runtime_interaction.cc b/components/voicelife_runtime_esp/src/esp_runtime_interaction.cc new file mode 100644 index 00000000..558fc3ee --- /dev/null +++ b/components/voicelife_runtime_esp/src/esp_runtime_interaction.cc @@ -0,0 +1,460 @@ +#include "esp_runtime_internal.h" + +#ifdef ESP_PLATFORM +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "im_binding_mcp_tools.h" +#include "linx_mcp_bridge.h" + +namespace voicelife::runtime { +std::string_view Runtime::PhaseStatusText(voice::VoiceInteractionState state) { + switch (state) { + case voice::VoiceInteractionState::kBooting: + return "开机"; + case voice::VoiceInteractionState::kStandby: + return "空闲"; + case voice::VoiceInteractionState::kOpeningCapture: + return "聆听中"; // 采集请求提交中(事务式启动过渡) + case voice::VoiceInteractionState::kListening: + return "聆听中"; + case voice::VoiceInteractionState::kFinalizing: + return "聆听中"; // 等待最终 STT,仍显示聆听 + case voice::VoiceInteractionState::kThinking: + return "处理中"; + case voice::VoiceInteractionState::kSpeaking: + return "说话中"; + case voice::VoiceInteractionState::kInterrupting: + return "停止"; + case voice::VoiceInteractionState::kReconnecting: + return "重连中"; + case voice::VoiceInteractionState::kError: + return "出错了"; + } + return "出错了"; +} + +voice::VoiceMood Runtime::PhaseMood(voice::VoiceInteractionState state) { + switch (state) { + case voice::VoiceInteractionState::kBooting: + return voice::VoiceMood::kBooting; + case voice::VoiceInteractionState::kStandby: + return voice::VoiceMood::kIdle; + case voice::VoiceInteractionState::kOpeningCapture: + case voice::VoiceInteractionState::kListening: + case voice::VoiceInteractionState::kFinalizing: + return voice::VoiceMood::kListening; + case voice::VoiceInteractionState::kThinking: + return voice::VoiceMood::kThinking; + case voice::VoiceInteractionState::kSpeaking: + return voice::VoiceMood::kSpeaking; + case voice::VoiceInteractionState::kInterrupting: + return voice::VoiceMood::kCancelled; + case voice::VoiceInteractionState::kReconnecting: + return voice::VoiceMood::kConnecting; + case voice::VoiceInteractionState::kError: + return voice::VoiceMood::kSad; + } + return voice::VoiceMood::kSad; +} + +std::string Runtime::CurrentStandbyStatusText() { + const time_t now = time(nullptr); + if (now <= 1600000000) return "空闲"; // 2020-09-13 之前视为尚未同步时钟。 + std::tm local{}; + localtime_r(&now, &local); + char clock_text[8] = {}; + std::snprintf(clock_text, sizeof(clock_text), "%02d:%02d", local.tm_hour, local.tm_min); + return clock_text; +} + +void Runtime::CommitSnapshot() { + if (snapshot_.revision == last_rendered_revision_) { + return; + } + last_rendered_revision_ = snapshot_.revision; + // 显示语义通过 PresentationPort 提交;渲染由板级 Adapter 完成。 + if (assembly_ != nullptr) { + (void)assembly_->presentation().Render(snapshot_); + } + ESP_LOGI(kTag, + "INTERACTION_SNAPSHOT phase=%d generation=%llu revision=%llu mood=%d status_bytes=%u role=%d " + "content_bytes=%u", + static_cast(snapshot_.phase), static_cast(snapshot_.generation), + static_cast(snapshot_.revision), static_cast(snapshot_.mood), + static_cast(snapshot_.status_text.size()), static_cast(snapshot_.role), + static_cast(snapshot_.content_text.size())); +} + +// 显示语义提交:只投递给 InteractionEventLoop,禁止在调用线程直接 Render。 +void Runtime::ShowDisplay(voice::VoiceMood mood, std::string_view status, std::string_view content) { + EnqueueDisplayUpdate(mood, status, content, false); +} + +// 临时 overlay 快照:由事件循环统一写入,revision 与业务快照保持严格单调。 +void Runtime::ShowOverlay(voice::VoiceMood mood, std::string_view status, std::string_view content) { + EnqueueDisplayUpdate(mood, status, content, true); +} + +void Runtime::StartOverlayTimer(uint32_t duration_ms) { + volume_overlay_until_us_ = esp_timer_get_time() + static_cast(duration_ms) * 1000; + if (volume_overlay_timer_ == nullptr) { + esp_timer_create_args_t args = {}; + args.callback = &VolumeOverlayEntry; + args.arg = this; + args.name = "voicelife_overlay"; + (void)esp_timer_create(&args, &volume_overlay_timer_); + } + if (volume_overlay_timer_ != nullptr) { + (void)esp_timer_stop(volume_overlay_timer_); + (void)esp_timer_start_once(volume_overlay_timer_, static_cast(duration_ms) * 1000ULL); + } +} + +// “收到!”是唤醒确认的短暂显示。即使服务端暂时没有后续语音事件, +// 也必须由事件循环在租约到期后主动刷新,否则 OLED 会永久保留确认文本。 +void Runtime::ClearExpiredWakeAck() { + if (wake_ack_until_us_ == 0 || esp_timer_get_time() < wake_ack_until_us_) return; + wake_ack_until_us_ = 0; + if (snapshot_.phase != voice::VoiceInteractionState::kListening || + snapshot_.role != voice::VoiceContentRole::kSystem || snapshot_.content_text != "收到!") { + return; + } + snapshot_.content_text.clear(); + snapshot_.role = voice::VoiceContentRole::kNone; + ++snapshot_.revision; + CommitSnapshot(); + ESP_LOGI(kTag, "WAKE_ACK_DISPLAY_EXPIRED=1"); +} + +Status Runtime::HandleInteractionEvent(voice::VoiceInteractionEvent event, std::string_view wake_word) { + active_wake_word_.assign(wake_word); + const Status status = interaction_task_host_.Submit({.voice_event = event}, *this); + if (!status.ok()) { + ESP_LOGW(kTag, "忽略乱序板端交互事件=%d: %s", static_cast(event), status.message.c_str()); + } + active_wake_word_.clear(); + return status; +} + +Status Runtime::Submit(application::InteractionAction transition) { + const voice::VoiceInteractionEvent event = transition.source; + // 新回合事件递增语义代次:显示任务按 generation -> revision 丢弃迟到快照。 + switch (event) { + case voice::VoiceInteractionEvent::kToggleChat: + case voice::VoiceInteractionEvent::kPressDown: + case voice::VoiceInteractionEvent::kWakeDetected: + case voice::VoiceInteractionEvent::kInterruptAndAcknowledge: + ++snapshot_.generation; + // A fresh user turn must never inherit a farewell decision + // from a disconnected or cancelled preceding turn. + terminal_turn_ = false; + binding_turn_awaiting_tts_completion_ = false; + break; + case voice::VoiceInteractionEvent::kInterruptRequested: + case voice::VoiceInteractionEvent::kTransportDisconnected: + case voice::VoiceInteractionEvent::kFailure: + // These paths invalidate the current remote turn before its + // normal TTS completion can safely decide the next UI state. + terminal_turn_ = false; + binding_turn_awaiting_tts_completion_ = false; + break; + default: + break; + } + // 会话阶段 → 显示模型快照:状态栏文本 + 表情由阶段派生。 + snapshot_.phase = transition.state; + snapshot_.mood = PhaseMood(snapshot_.phase); + if (snapshot_.phase != voice::VoiceInteractionState::kStandby && binding_terminal_display_active_) { + CancelBindingTerminalDisplay(); + } + // 空闲态显示当前时间(若服务端时间已初始化),否则显示状态词。 + if (snapshot_.phase == voice::VoiceInteractionState::kStandby) { + snapshot_.status_text = CurrentStandbyStatusText(); + } else { + snapshot_.status_text = PhaseStatusText(snapshot_.phase); + } + // 事件驱动的内容角色切换: + // - kIntentReceived(STT):内容栏显示用户语音,角色 user + // - kTtsStarted:内容栏保持/显示助手文本,角色 assistant + // - 会话结束/回待机:清空内容栏 + // WakeAck 租约:唤醒后短窗(400ms)内显示“收到!”,不阻塞开麦。 + if ((event == voice::VoiceInteractionEvent::kWakeDetected || + event == voice::VoiceInteractionEvent::kInterruptAndAcknowledge) && + snapshot_.phase == voice::VoiceInteractionState::kListening && wake_ack_until_us_ > 0 && + esp_timer_get_time() < wake_ack_until_us_) { + snapshot_.content_text = "收到!"; + snapshot_.role = voice::VoiceContentRole::kSystem; + } else if (event == voice::VoiceInteractionEvent::kEndpointDetected) { + // VAD 端点:进入 kFinalizing 等待最终 STT,清掉“收到!”残留, + // 显示“聆听中”状态词。 + wake_ack_until_us_ = 0; + snapshot_.content_text.clear(); + snapshot_.role = voice::VoiceContentRole::kNone; + } else if (event == voice::VoiceInteractionEvent::kIntentReceived && !stt_display_text_.empty()) { + snapshot_.content_text = stt_display_text_; + snapshot_.role = voice::VoiceContentRole::kUser; + } else if (event == voice::VoiceInteractionEvent::kTtsStopped || + event == voice::VoiceInteractionEvent::kStandbyReady || + event == voice::VoiceInteractionEvent::kBootCompleted) { + snapshot_.content_text.clear(); + snapshot_.role = voice::VoiceContentRole::kNone; + } + // 绑定码不是一帧临时字幕。普通语音回合可以覆盖它,但回到待机后必须 + // 恢复当前 pending 会话的六码与有效期,直到 Gateway 返回终态。 + if (snapshot_.phase == voice::VoiceInteractionState::kStandby && binding_display_active_ && + binding_display_generation_ == binding_use_case_.generation()) { + snapshot_.mood = voice::VoiceMood::kNeutral; + snapshot_.status_text = binding_status_text_; + snapshot_.content_text = binding_content_text_; + snapshot_.role = voice::VoiceContentRole::kSystem; + } + // 冗余 standby_ready 不得让绑定终态一闪而过;进入任何活跃状态 + // 会在上方取消租约,使新交互立即接管显示。 + if (snapshot_.phase == voice::VoiceInteractionState::kStandby && binding_terminal_display_active_) { + snapshot_.mood = binding_terminal_mood_; + snapshot_.status_text = binding_terminal_status_text_; + snapshot_.content_text = binding_terminal_content_text_; + snapshot_.role = voice::VoiceContentRole::kSystem; + } + ++snapshot_.revision; + // 真实状态迁移优先于临时 overlay,过期信号不能恢复旧回合的 UI。 + overlay_active_ = false; + CommitSnapshot(); + QueueDeferredBindingSpeechIfStandby(); + switch (transition.directive) { + case voice::VoiceInteractionAction::kNone: + return Status::Ok(); + case voice::VoiceInteractionAction::kStartCapture: + QueueCaptureStart(); + return Status::Ok(); + case voice::VoiceInteractionAction::kStartVoiceTurn: + if (active_wake_word_.empty()) { + return Status::Error(ErrorCode::kInvalidArgument, "本地唤醒词不能为空"); + } + QueueVoiceTurn(active_wake_word_); + return Status::Ok(); + case voice::VoiceInteractionAction::kStopVoiceTurn: + QueueCaptureStop(); + return Status::Ok(); + case voice::VoiceInteractionAction::kInterruptAndStartCapture: + QueueInterruptAndCapture(); + return Status::Ok(); + case voice::VoiceInteractionAction::kInterruptAndStartVoiceTurn: + if (active_wake_word_.empty()) { + return Status::Error(ErrorCode::kInvalidArgument, "本地打断词不能为空"); + } + QueueInterruptAndVoiceTurn(active_wake_word_); + return Status::Ok(); + case voice::VoiceInteractionAction::kRestoreStandby: + // transport_disconnected 必须停在 kReconnecting;物理唤醒门可恢复, + // 但不可用 kStandbyReady 把可见状态提前伪装为空闲。 + QueueStandbyRecovery(transition.state != voice::VoiceInteractionState::kReconnecting); + return Status::Ok(); + case voice::VoiceInteractionAction::kInterruptSession: + QueueInterrupt(); + return Status::Ok(); + } + return Status::Error(ErrorCode::kInternal, "未知板端交互动作"); +} + +void Runtime::LogVoiceEvidence(const voice::VoiceEvidence& evidence) { EnqueueVoiceEvidence(evidence); } + +void Runtime::ProcessVoiceEvidence(const voice::VoiceEvidence& evidence) { + // Evidence detail can contain STT text or service diagnostics. Emit + // only lifecycle names and numeric counters needed for board review. + if (evidence.event == "capture_started") { + capture_started_us_.store(esp_timer_get_time()); + StartListenTimer(kListenStartTimeoutMs); + } + const int64_t started_at = capture_started_us_.load(); + const int64_t now = esp_timer_get_time(); + const uint64_t latency_ms = + started_at > 0 && now >= started_at ? static_cast((now - started_at) / 1000) : 0; + if (assembly_ != nullptr) assembly_->LogAudioStats(); + ESP_LOGI(kTag, "VOICE_HEAP event=%s internal_free=%u internal_largest=%u psram_free=%u", evidence.event.c_str(), + static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)), + static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM))); + ESP_LOGI(kTag, "VOICE_EVENT session=%s generation=%llu event=%s detail_present=%d latency_from_capture_ms=%llu", + evidence.session_id.c_str(), static_cast(evidence.generation), evidence.event.c_str(), + evidence.detail.empty() ? 0 : 1, static_cast(latency_ms)); + if (evidence.event == "provider_error") { + // 板端诊断:只输出本地错误消息(不包含 STT 文本、凭据或原始响应)。 + ESP_LOGW(kTag, "PROVIDER_ERROR_DETAIL=%.160s", evidence.detail.c_str()); + } + if (evidence.event == "tts_started" && wake_ack_requested_at_us_ > 0) { + const int64_t wake_latency_ms = (esp_timer_get_time() - wake_ack_requested_at_us_) / 1000; + if (wake_latency_ms >= 0 && wake_latency_ms <= 10000) { + wake_ack_tts_started_at_us_ = esp_timer_get_time(); + ESP_LOGI(kTag, "WAKE_ACK_LATENCY stage=tts_started ms=%lld", static_cast(wake_latency_ms)); + } + } else if (evidence.event == "tts_first_audio" && wake_ack_tts_started_at_us_ > 0) { + const int64_t audio_latency_ms = (esp_timer_get_time() - wake_ack_requested_at_us_) / 1000; + ESP_LOGI(kTag, "WAKE_ACK_LATENCY stage=first_audio ms=%lld", static_cast(audio_latency_ms)); + } else if (evidence.event == "tts_stopped" && wake_ack_tts_started_at_us_ > 0) { + // 只关闭已经确认属于本次唤醒提示的计时窗口;后续回答的 TTS + // 不得被误归类为首次确认时延。 + wake_ack_requested_at_us_ = 0; + wake_ack_tts_started_at_us_ = 0; + } + if (evidence.event == "tts_stopped" || evidence.event == "tts_aborted" || evidence.event == "provider_error" || + evidence.event == "capture_stop_failed" || evidence.event == "tts_capture_stop_failed") { + capture_started_us_.store(0); + } + if (evidence.event == "capture_started") { + (void)EnqueueEvent(voice::VoiceInteractionEvent::kCaptureStarted); + } else if (evidence.event == "stt_text_received") { + // 收到用户语音转写(STT):取消聆听超时,等待服务端回复。 + CancelListenTimer(); + // 抑制唤醒词被回传为 STT:唤醒后 1.5s 内收到等于唤醒词的文本, + // 视为服务端把唤醒词误转写,不显示、不武装回复、不发 kIntentReceived。 + const bool wake_echo = !last_wake_word_.empty() && evidence.detail == last_wake_word_ && + (last_wake_at_ > 0 && esp_timer_get_time() - last_wake_at_ < 1500 * 1000LL); + if (wake_echo) { + ESP_LOGI(kTag, "WAKE_ECHO_SUPPRESSED"); + // 中止该合成回合,避免服务端据此生成问候 TTS;随后由聆听超时/新输入重启。 + if (session_) { + (void)session_->Interrupt(); + } + return; + } + // 回写用户说的话到屏幕(detail 是 ASR 文本,属于用户自己的输入)。 + if (!evidence.detail.empty()) { + stt_display_text_ = evidence.detail; + // 终止意图识别:再见/拜拜/bye 等 → 播报结束后不 follow-up,直接收尾。 + terminal_turn_ = + (evidence.detail.find("再见") != std::string::npos || + evidence.detail.find("拜拜") != std::string::npos || + evidence.detail.find("bye") != std::string::npos || evidence.detail.find("拜") != std::string::npos || + evidence.detail.find("走了") != std::string::npos); + } + (void)EnqueueEvent(voice::VoiceInteractionEvent::kIntentReceived); + if (terminal_turn_) { + // 不等待服务端针对“再见”的自由回复。先取消旧回合,再以 Linx + // text_response 请求固定告别语,因此只会播放“牛牛走了~”。 + QueueSystemSpeech("牛牛走了~"); + } + } else if (evidence.event == "tool_call_received") { + // MCP 工具调用(服务端发现/工具执行)不是用户语音意图: + // 仅取消聆听超时,不武装回复、不触发 kIntentReceived。 + CancelListenTimer(); + } else if (evidence.event == "mcp_tool_started") { + // MCP worker 只经 VoiceSession evidence 投递;状态机决定是否允许 + // 从当前交互态进入“处理中”,不得由 worker 自己写快照。 + CancelListenTimer(); + const auto phase = interaction_orchestrator_.state(); + if (phase == voice::VoiceInteractionState::kListening || phase == voice::VoiceInteractionState::kFinalizing || + phase == voice::VoiceInteractionState::kThinking) { + (void)EnqueueEvent(voice::VoiceInteractionEvent::kIntentReceived); + } + } else if (evidence.event == "mcp_tool_result" || evidence.event == "mcp_tool_failed") { + const bool success = evidence.event == "mcp_tool_result"; + // 绑定工具由 BindingPresentation 显示真实绑定码/终态。通用工具 + // overlay 不得用“日程操作已完成”等摘要覆盖绑定页面。 + if (IsBindingMcpToolSummary(evidence.detail)) { + ESP_LOGI(kTag, "IM_BINDING_TOOL_OVERLAY_SUPPRESSED=1"); + return; + } + // evidence.detail 不是可信的用户文本。仅接受 MCP worker 产生的 + // 固定业务短句;任何原始 JSON-RPC/MCP 内容都降级为通用文案。 + std::string_view summary = success ? "操作已完成" : "操作失败"; + std::string_view status = success ? "操作结果" : "操作错误"; + if (success && evidence.detail == "日程已创建") { + summary = "日程已创建"; + status = "日程结果"; + } else if (success && evidence.detail == "日程查询完成") { + summary = "日程查询完成"; + status = "日程结果"; + } else if (!success && evidence.detail == "日程创建失败") { + summary = "日程创建失败"; + status = "日程错误"; + } else if (!success && evidence.detail == "日程查询失败") { + summary = "日程查询失败"; + status = "日程错误"; + } + ShowOverlay(success ? voice::VoiceMood::kHappy : voice::VoiceMood::kSad, status, summary); + StartOverlayTimer(2500); + } else if (evidence.event == "tts_started") { + CancelListenTimer(); + (void)EnqueueEvent(voice::VoiceInteractionEvent::kTtsStarted); + } else if (evidence.event == "local_wake_ack_requested" || evidence.event == "interrupt_ack_requested") { + // 本地唤醒/打断确认已经成功提交给 Provider,但真正的 tts.start + // 可能永远不到达(断线或服务端无响应)。此时 UI 已处于 + // kListening,必须有边界地回到待机,不能无限显示“聆听中”。 + StartListenTimer(kListenStartTimeoutMs); + } else if (evidence.event == "tts_sentence_started") { + // 回写服务端回复句子到屏幕(detail 为 TTS 文本),并立即提交快照 + // 让“说话中 + 助手文本”可见(不再停留显示用户 STT)。 + // 门控:仅当 Controller 已接受 kTtsStarted(处于 kSpeaking)才改显示; + // 迟到的 TTS(Controller 已回 Standby/Error)直接丢弃,避免绕过状态机 + // 把屏幕卡在“说话中”。 + if (interaction_orchestrator_.state() != voice::VoiceInteractionState::kSpeaking) { + ESP_LOGI(kTag, "TTS_SENTENCE_STALE state=%d 丢弃迟到句子", + static_cast(interaction_orchestrator_.state())); + return; + } + CancelListenTimer(); + if (!evidence.detail.empty()) { + // 事件化:文本经事件循环应用(唯一写者),门控仍在事件循环校验。 + stt_display_text_ = evidence.detail; + EnqueueDisplayText(evidence.detail); + } + } else if (evidence.event == "tts_stopped" || evidence.event == "tts_aborted") { + CancelListenTimer(); + // Provider disconnect/reconnect may deliver the completion of an + // already-aborted remote TTS turn. It has no visible meaning once + // the interaction loop has restored standby (or entered another + // terminal state), so it must not re-enter the controller and + // produce a false ordering error. + if (interaction_orchestrator_.state() != voice::VoiceInteractionState::kSpeaking) { + ESP_LOGI(kTag, "TTS_STOPPED_STALE state=%d 丢弃迟到结束事件", + static_cast(interaction_orchestrator_.state())); + return; + } + if (terminal_turn_ || binding_turn_awaiting_tts_completion_) { + // 告别或绑定码播报完成后直接恢复待机。绑定码页面会在 + // HandleInteractionEvent 的待机呈现规则中立即恢复。 + terminal_turn_ = false; + binding_turn_awaiting_tts_completion_ = false; + (void)EnqueueEvent(voice::VoiceInteractionEvent::kTerminalResponseCompleted); + } else { + // 事件化:kTtsStopped 由事件循环唯一执行状态迁移。 + EnqueueEvent(voice::VoiceInteractionEvent::kTtsStopped); + } + } else if (evidence.event == "transport_disconnected") { + CancelListenTimer(); + (void)EnqueueEvent(voice::VoiceInteractionEvent::kTransportDisconnected); + } else if (evidence.event == "transport_connected") { + (void)EnqueueEvent(voice::VoiceInteractionEvent::kTransportConnected); + } else if (evidence.event == "provider_error" || evidence.event == "capture_stop_failed" || + evidence.event == "tts_capture_stop_failed") { + CancelListenTimer(); + // 会话已回待机后收到的 provider_error(如服务端有序 FIN/断开)是 + // 正常断线,不当作故障;随后的 transport_disconnected 走自动重连。 + // 仅会话进行中(聆听/处理/播报)的 provider_error 才算真正故障。 + const auto phase = interaction_orchestrator_.state(); + if (phase != voice::VoiceInteractionState::kStandby) { + (void)EnqueueEvent(voice::VoiceInteractionEvent::kFailure); + } + } else if (evidence.event == "capture_stopped") { + // kFinalizing(等最终 STT)时不得取消 5s 最终 STT 定时器, + // 否则服务端不返回 STT 时会永久悬挂;其余状态取消。 + if (interaction_orchestrator_.state() != voice::VoiceInteractionState::kFinalizing) { + CancelListenTimer(); + } + } else if (evidence.event == "vad_silence") { + // 本地 VAD 端点:用户说完话后静音 1200ms,发 listen.stop 使服务端 + // 进入最终 STT,然后等待最终 STT(kFinalizing),不回待机。 + // 启动 5s 最终 STT 超时:无 STT 则 abort 收尾。 + CancelListenTimer(); + if (interaction_orchestrator_.state() == voice::VoiceInteractionState::kListening) { + (void)EnqueueEvent(voice::VoiceInteractionEvent::kEndpointDetected); + StartListenTimer(kFinalSttTimeoutMs); + } + } +} +} // namespace voicelife::runtime +#endif diff --git a/components/voicelife_runtime_esp/src/esp_runtime_internal.h b/components/voicelife_runtime_esp/src/esp_runtime_internal.h new file mode 100644 index 00000000..7dc19f69 --- /dev/null +++ b/components/voicelife_runtime_esp/src/esp_runtime_internal.h @@ -0,0 +1,308 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "voicelife/application/interaction_orchestrator.h" +#include "voicelife/contracts/json.h" +#include "voicelife/im/esp_http_transport_factory.h" +#include "voicelife/im/im_binding_use_case.h" +#include "voicelife/im/im_config_store.h" +#include "voicelife/im/im_runtime.h" +#include "voicelife/linx/linx_speech_provider.h" +#include "voicelife/linx/linx_types.h" +#include "voicelife/linx_esp/esp_websocket_transport.h" +#include "voicelife/mcp/mcp_server.h" +#include "voicelife/runtime/platform_assembly.h" +#include "voicelife/runtime_esp/esp_interaction_task_host.h" +#include "voicelife/schedule/schedule_operation_service.h" +#include "voicelife/schedule/schedule_rule_service.h" +#include "voicelife/schedule/schedule_service.h" +#include "voicelife/voice/display_snapshot.h" +#include "voicelife/voice/voice_interaction_controller.h" +#include "voicelife/voice/voice_ports.h" +#include "voicelife/voice/voice_session.h" + +#ifdef ESP_PLATFORM +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#endif + +#include "bootstrap/storage_bootstrap.h" +#include "im_binding_polling_lease.h" +#include "im_binding_presentation.h" +#include "im_runtime_bootstrap.h" + +namespace voicelife::runtime { + +#ifdef ESP_PLATFORM +constexpr char kTag[] = "VoiceLifeRuntime"; +constexpr int64_t kWakeAckDisplayUs = 400 * 1000; +constexpr int64_t kVolumeOverlayUs = 1500 * 1000; +constexpr uint32_t kListenStartTimeoutMs = 6000; +constexpr uint32_t kFinalSttTimeoutMs = 5000; +#if CONFIG_VOICELIFE_IM_GATEWAY +constexpr bool kImGatewayEnabled = true; +#else +constexpr bool kImGatewayEnabled = false; +#endif + +class NvsSecretResolver final : public linx_esp::SecretResolverPort { + public: + Result Resolve(std::string_view reference) override; +}; +#endif + +class ScaffoldAudioInput final : public voice::AudioInputPort { + public: + void SetAudioSink(voice::AudioFrameSink) override {} + Status Open(const voice::AudioFormat&) override { return Status::Ok(); } + Status StartCapture(voice::VoiceMode) override { return Status::Ok(); } + Status StopCapture() override { return Status::Ok(); } + void Close() override {} +}; + +class ScaffoldAudioOutput final : public voice::AudioOutputPort { + public: + Status Open(const voice::AudioFormat&) override { return Status::Ok(); } + Status Push(const voice::AudioFrame&) override { return Status::Ok(); } + Status Flush() override { return Status::Ok(); } + bool IsIdle() const override { return true; } + void Close() override {} +}; + +class ScaffoldSpeechProvider final : public voice::SpeechProviderAdapter { + public: + Status Connect(const voice::VoiceSessionConfig&, voice::VoiceEventSink) override { return Status::Ok(); } + Status StartCapture(voice::VoiceMode) override { return Status::Ok(); } + Status StopCapture() override { return Status::Ok(); } + Status SendAudio(const voice::AudioFrame&) override { return Status::Ok(); } + Status Abort(std::string_view) override { return Status::Ok(); } + Status Speak(std::string_view) override { return Status::Ok(); } + Status NotifyLocalWakeWord(std::string_view, std::string_view = {}) override { return Status::Ok(); } + Status Disconnect() override { return Status::Ok(); } + Result audio_formats() const override; + const voice::CapabilityProfile& capabilities() const override { return profile_; } + + private: + voice::CapabilityProfile profile_{"scaffold", {"streaming-asr", "tts"}}; +}; + +class Runtime final : public application::InteractionActionSink { + public: + Runtime(); + Status Start(PlatformAssembly& assembly); + Status RequestInterrupt(); + + private: + StorageBootstrap storage_; +#ifdef ESP_PLATFORM + struct McpRequest { + std::string payload; + std::string session_id; + std::mutex mutex; + std::condition_variable completed_cv; + std::optional> response; + bool completed = false; + std::atomic_bool abandoned{false}; + }; + enum class BoardRequestKind : uint8_t{ + kWakeWord, kInterruptAndWakeWord, kRestoreStandby, kInterrupt, + kStartCapture, kStopCapture, kInterruptAndStartCapture, + }; + struct BoardRequest { + BoardRequestKind kind = BoardRequestKind::kRestoreStandby; + char wake_word[32]; + bool settle_controller = true; + char system_speech[kBindingSystemSpeechCapacity]; + }; + struct InteractionEventItem { + voice::VoiceInteractionEvent event = voice::VoiceInteractionEvent::kBootCompleted; + std::string wake_word; + std::string display_text; + bool display_only = false; + bool display_update = false; + bool display_overlay = false; + voice::VoiceMood display_mood = voice::VoiceMood::kIdle; + std::string display_status; + std::string display_content; + bool voice_evidence = false; + voice::VoiceEvidence evidence; + bool binding_result = false; + im::BindingResult binding; + bool binding_reset = false; + uint64_t binding_generation = 0; + bool listen_timeout = false; + bool network_update = false; + bool network_connected = false; + bool board_input = false; + BoardInputAction board_action = BoardInputAction::kToggleChat; + }; + + void StopEventLoop(); + Status StartMcpWorker(); + void StopMcpWorker(); + void StartBindingPolling(uint64_t generation); + static void BindingPollTaskEntry(void* context); + void BindingPollLoop(); + static std::string TruncateUtf8(std::string_view value, std::size_t max_bytes); + static bool IsMcpToolCall(std::string_view payload); + Result HandleMcpRequest(std::string_view payload, std::string_view session_id); + static void McpWorkerTaskEntry(void* arg); + void McpWorkerLoop(); + void StartImRuntime(); + static void ImLifecycleTaskEntry(void* context); + void ImLifecycleTask(); + void EnqueueBoardInput(BoardInputAction action); + void SetVolume(int volume); + void QueueWakeWord(std::string_view wake_word); + void QueueVoiceTurn(std::string_view wake_word); + void QueueInterruptAndVoiceTurn(std::string_view wake_word); + void QueueStandbyRecovery(bool settle_controller = true); + bool QueueSystemSpeech(std::string_view text); + static void VolumeOverlayEntry(void* context); + static void ListenTimeoutEntry(void* context); + void StartListenTimer(uint32_t timeout_ms); + void CancelListenTimer(); + void QueueInterrupt(); + void QueueCaptureStart(); + void QueueCaptureStop(); + void QueueInterruptAndCapture(); +#if CONFIG_VOICELIFE_STATE_FLOW_TEST + Status StartStateFlowDiagnostic(); + static void StateFlowTaskEntry(void* context); + void StateFlowEvent(uint32_t step, voice::VoiceInteractionEvent event); + void StateFlowEvidence(uint32_t step, std::string_view event, std::string_view detail = {}); + void StateFlowTask(); +#endif + void RestoreStandby(const BoardRequest& request); + static void WakeTaskEntry(void* context); + void WakeTask(); + static std::string_view PhaseStatusText(voice::VoiceInteractionState state); + static voice::VoiceMood PhaseMood(voice::VoiceInteractionState state); + static std::string CurrentStandbyStatusText(); + void CommitSnapshot(); + void ShowDisplay(voice::VoiceMood mood, std::string_view status, std::string_view content); + void ShowOverlay(voice::VoiceMood mood, std::string_view status, std::string_view content); + void StartOverlayTimer(uint32_t duration_ms); + void ClearExpiredWakeAck(); + Status HandleInteractionEvent(voice::VoiceInteractionEvent event, std::string_view wake_word = {}); + Status Submit(application::InteractionAction transition) override; + void LogVoiceEvidence(const voice::VoiceEvidence& evidence); + void ProcessVoiceEvidence(const voice::VoiceEvidence& evidence); + void EnqueueEvent(voice::VoiceInteractionEvent event, std::string_view wake_word = {}); + void EnqueueDisplayText(std::string detail); + void EnqueueDisplayUpdate(voice::VoiceMood mood, std::string_view status, std::string_view content, bool overlay); + void EnqueueBindingResult(const im::BindingResult& result); + void EnqueueBindingReset(uint64_t generation); + void CancelBindingTerminalDisplay(); + void ClearExpiredBindingTerminalDisplay(); + void CommitBindingPresentation(const BindingPresentation& presentation); + void QueueDeferredBindingSpeechIfStandby(); + void ProcessBindingResult(const im::BindingResult& result); + void EnqueueVoiceEvidence(const voice::VoiceEvidence& evidence); + void EnqueueListenTimeout(); + void EnqueueNetworkState(bool connected); + static void EventLoopTaskEntry(void* arg); + void EventLoopLoop(); + + static constexpr std::size_t kMcpWorkerQueueCapacity = 4; + static constexpr uint32_t kBindingPollIntervalMs = 3000; + static constexpr uint32_t kBindingPollStackBytes = 16384; + static constexpr std::size_t kEventQueueCapacity = 16; + NvsSecretResolver linx_secrets_; + NvsImSecretStore im_secret_store_; + im::StoredImConfigProvider im_config_{im_secret_store_, kImGatewayEnabled}; + EspImRuntimeReadiness im_readiness_; + im::ImRuntime im_runtime_{im_config_, im_config_, im_readiness_, + [](const std::string& origin) { return im::CreateEspHttpTransport(origin); }}; + EspPairingClock im_pairing_clock_; + im::BindingUseCase binding_use_case_; + BindingPollingLease binding_poll_lease_; + bool binding_display_active_ = false; + uint64_t binding_display_generation_ = 0; + std::string binding_status_text_; + std::string binding_content_text_; + std::optional deferred_binding_presentation_; + std::string deferred_binding_speech_; + std::atomic_bool im_lifecycle_started_{false}; + TaskHandle_t im_lifecycle_task_ = nullptr; + mcp::McpServer mcp_server_; + schedule::ScheduleService schedule_service_; + schedule::ScheduleOperationService schedule_operation_service_; + schedule::ScheduleRuleService schedule_rule_service_; + Status init_status_ = Status::Ok(); + linx::LinxJsonCodec linx_codec_; + linx::LinxConnectionConfig linx_config_; + std::unique_ptr linx_transport_ = + std::make_unique(linx_secrets_); + QueueHandle_t wake_queue_ = nullptr; + TaskHandle_t wake_task_ = nullptr; +#if CONFIG_VOICELIFE_STATE_FLOW_TEST + TaskHandle_t state_flow_task_ = nullptr; +#endif + std::deque event_queue_; + mutable std::mutex event_mutex_; + std::condition_variable event_cv_; + TaskHandle_t event_task_ = nullptr; + bool event_loop_stop_ = false; + bool event_loop_stopped_ = false; + std::atomic overlay_expired_{false}; + std::mutex mcp_mutex_; + std::condition_variable mcp_cv_; + std::deque> mcp_queue_; + TaskHandle_t mcp_task_ = nullptr; + bool mcp_stop_ = false; + std::atomic_bool mcp_stopped_{true}; + int volume_ = 70; + std::atomic capture_started_us_{0}; + std::string stt_display_text_; + bool terminal_turn_ = false; + bool binding_turn_awaiting_tts_completion_ = false; + bool binding_terminal_display_active_ = false; + bool binding_terminal_resume_listening_ = false; + voice::VoiceMood binding_terminal_mood_ = voice::VoiceMood::kNeutral; + std::string binding_terminal_status_text_; + std::string binding_terminal_content_text_; + int64_t binding_terminal_until_us_ = 0; + std::string last_wake_word_; + int64_t last_wake_at_ = 0; + int64_t wake_ack_requested_at_us_ = 0; + int64_t wake_ack_tts_started_at_us_ = 0; + int64_t wake_ack_until_us_ = 0; + int64_t volume_overlay_until_us_ = 0; + esp_timer_handle_t volume_overlay_timer_ = nullptr; + voice::DisplaySnapshot snapshot_; + voice::DisplaySnapshot overlay_base_snapshot_; + bool overlay_active_ = false; + uint64_t last_rendered_revision_ = 0; + PlatformAssembly* assembly_ = nullptr; + esp_timer_handle_t listen_timer_ = nullptr; +#else + ScaffoldAudioInput audio_input_; + ScaffoldAudioOutput audio_output_; +#endif + application::InteractionOrchestrator interaction_orchestrator_; + runtime_esp::EspInteractionTaskHost interaction_task_host_{interaction_orchestrator_}; + std::string active_wake_word_; + std::unique_ptr provider_; + std::unique_ptr session_; +}; + +Runtime& Instance(); +Status StartEspImpl(PlatformAssembly& assembly); +Status RequestInterruptEspImpl(); + +} // namespace voicelife::runtime diff --git a/components/voicelife_runtime_esp/src/esp_runtime_workers.cc b/components/voicelife_runtime_esp/src/esp_runtime_workers.cc new file mode 100644 index 00000000..6d0bc2d8 --- /dev/null +++ b/components/voicelife_runtime_esp/src/esp_runtime_workers.cc @@ -0,0 +1,249 @@ +#include "esp_runtime_internal.h" + +#ifdef ESP_PLATFORM +#include "esp_log.h" +#include "im_binding_mcp_tools.h" +#include "linx_mcp_bridge.h" +#include "mcp_worker_policy.h" +#include "voicelife/im/im_retry_policy.h" + +namespace voicelife::runtime { +void Runtime::StopEventLoop() { + if (event_task_ == nullptr) return; + { + std::lock_guard lock(event_mutex_); + event_queue_.clear(); + event_loop_stop_ = true; + } + event_cv_.notify_one(); + for (int attempt = 0; attempt < 20 && !event_loop_stopped_; ++attempt) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + event_task_ = nullptr; +} +Status Runtime::StartMcpWorker() { + std::lock_guard lock(mcp_mutex_); + if (mcp_task_ != nullptr) { + // 旧 worker 可能仍在执行网络请求;未确认退出前不得重建,避免双 worker + // 并发访问队列、MCP server 与 BindingUseCase。 + if (!mcp_stopped_.load()) { + return Status::Error(ErrorCode::kInternal, "MCP 工作任务尚未退出"); + } + mcp_task_ = nullptr; // 任务已自删,仅句柄残留。 + } + mcp_stop_ = false; + mcp_stopped_.store(false); + if (xTaskCreate(&Runtime::McpWorkerTaskEntry, "voicelife_mcp", 32768, this, 4, &mcp_task_) != pdPASS) { + return Status::Error(ErrorCode::kInternal, "创建 MCP 工作任务失败"); + } + ESP_LOGI(kTag, "MCP_WORKER_READY capacity=%u", static_cast(kMcpWorkerQueueCapacity)); + return Status::Ok(); +} + +void Runtime::StopMcpWorker() { + { + std::lock_guard lock(mcp_mutex_); + if (mcp_task_ == nullptr) return; + mcp_stop_ = true; + for (const auto& request : mcp_queue_) request->abandoned.store(true); + mcp_queue_.clear(); + } + mcp_cv_.notify_all(); + // 有界等待任务确认退出。worker 内 HTTPS 请求最长约 10s(传输层超时), + // 等待上限给足 5s;仍未退出时保留句柄并报错,拒绝在旧任务存续期重建。 + constexpr int kStopWaitAttempts = 500; + for (int attempt = 0; attempt < kStopWaitAttempts && !mcp_stopped_.load(); ++attempt) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (mcp_stopped_.load()) { + std::lock_guard lock(mcp_mutex_); + mcp_task_ = nullptr; + } else { + ESP_LOGE(kTag, "MCP_WORKER_STOP_TIMEOUT=1 task_still_running=1"); + } +} + +void Runtime::StartBindingPolling(uint64_t generation) { + if (!binding_poll_lease_.Acquire(generation)) { + ESP_LOGI(kTag, "IM_BINDING_POLL_ADOPTED generation=%llu", static_cast(generation)); + return; + } + if (xTaskCreate(&Runtime::BindingPollTaskEntry, "voicelife_binding_poll", kBindingPollStackBytes, this, 2, + nullptr) != pdPASS) { + if (binding_poll_lease_.Release(generation)) { + EnqueueBindingResult(binding_use_case_.AbortPending(generation)); + } + ESP_LOGW(kTag, "IM_BINDING_POLL_TASK_FAILED=1"); + return; + } + ESP_LOGI(kTag, "IM_BINDING_POLL_STARTED generation=%llu", static_cast(generation)); +} + +void Runtime::BindingPollTaskEntry(void* context) { static_cast(context)->BindingPollLoop(); } + +void Runtime::BindingPollLoop() { + while (true) { + const uint64_t owner_generation = binding_poll_lease_.generation(); + vTaskDelay(pdMS_TO_TICKS(kBindingPollIntervalMs)); + const im::BindingResult result = binding_use_case_.Poll(); + if (result.state == im::BindingState::kPending || result.state == im::BindingState::kWaiting || + result.state == im::BindingState::kRetrying) { + continue; + } + // 轮询任务只投递脱敏语义结果。事件循环按 BindingUseCase generation + // 丢弃 origin/凭据变更后迟到的旧 confirmed,绝不直接访问显示或语音硬件。 + EnqueueBindingResult(result); + // 终态或会话已释放。若新 Start 在旧任务退出窗口接管租约,Release + // 会失败,本任务继续服务新会话,避免出现 pending 却没有轮询任务。 + if (binding_use_case_.active()) continue; + if (binding_poll_lease_.Release(owner_generation)) { + ESP_LOGI(kTag, "IM_BINDING_STATUS=%s stack_high_water=%u", BindingStatusName(result.state), + static_cast(uxTaskGetStackHighWaterMark(nullptr))); + break; + } + } + ESP_LOGI(kTag, "IM_BINDING_POLL_STOPPED=1"); + vTaskDelete(nullptr); +} +std::string Runtime::TruncateUtf8(std::string_view value, std::size_t max_bytes) { + if (value.size() <= max_bytes) return std::string(value); + std::size_t end = max_bytes; + while (end > 0 && (static_cast(value[end]) & 0xC0U) == 0x80U) --end; + return std::string(value.substr(0, end)) + "..."; +} + +bool Runtime::IsMcpToolCall(std::string_view payload) { + JsonValue request; + if (!ParseJson(payload, request).ok() || !request.IsObject()) return false; + const JsonValue* method = request.Get("method"); + return method != nullptr && method->IsString() && method->string == "tools/call"; +} + +Result Runtime::HandleMcpRequest(std::string_view payload, std::string_view session_id) { + auto request = std::make_shared(); + request->payload.assign(payload); + request->session_id.assign(session_id); + { + std::lock_guard lock(mcp_mutex_); + if (mcp_stop_ || mcp_task_ == nullptr || mcp_queue_.size() >= kMcpWorkerQueueCapacity) { + ESP_LOGW(kTag, "MCP_REQUEST_REJECTED reason=queue_full"); + return BuildLinxMcpUnavailableResponse(payload, "设备 MCP 正忙,请稍后重试", session_id); + } + mcp_queue_.push_back(request); + } + ESP_LOGI(kTag, "MCP_REQUEST_QUEUED bytes=%u", static_cast(payload.size())); + mcp_cv_.notify_one(); + + std::unique_lock lock(request->mutex); + if (!request->completed_cv.wait_for(lock, std::chrono::milliseconds(kMcpResponseTimeoutMs), + [&] { return request->completed; })) { + request->abandoned.store(true); + ESP_LOGW(kTag, "MCP_REQUEST_REJECTED reason=timeout"); + return BuildLinxMcpUnavailableResponse(payload, "设备 MCP 响应超时", session_id); + } + return std::move(*request->response); +} + +void Runtime::McpWorkerTaskEntry(void* arg) { static_cast(arg)->McpWorkerLoop(); } + +void Runtime::McpWorkerLoop() { + while (true) { + std::shared_ptr request; + { + std::unique_lock lock(mcp_mutex_); + mcp_cv_.wait(lock, [this] { return mcp_stop_ || !mcp_queue_.empty(); }); + if (mcp_stop_ && mcp_queue_.empty()) break; + request = std::move(mcp_queue_.front()); + mcp_queue_.pop_front(); + } + if (request->abandoned.load()) continue; + const bool tool_call = IsMcpToolCall(request->payload); + if (tool_call && session_) session_->ReportToolCallStarted(); + auto response = HandleLinxMcpPayload(request->payload, mcp_server_, request->session_id); + if (!response.ok()) { + response = BuildLinxMcpUnavailableResponse(request->payload, "设备 MCP 执行失败", request->session_id); + } + if (tool_call && !request->abandoned.load() && session_) { + const LinxMcpToolOutcome outcome = InspectLinxMcpToolOutcome(request->payload, response); + session_->ReportToolResult(TruncateUtf8(outcome.summary, 96), outcome.success); + } + ESP_LOGI(kTag, "MCP_TOOL_EXECUTED tool_call=%d result=%d", tool_call ? 1 : 0, response.ok() ? 1 : 0); + { + std::lock_guard lock(request->mutex); + if (!request->abandoned.load()) { + request->response = std::move(response); + request->completed = true; + } + } + request->completed_cv.notify_one(); + } + mcp_stopped_.store(true); + vTaskDelete(nullptr); +} +void Runtime::StartImRuntime() { +#if CONFIG_VOICELIFE_IM_GATEWAY + bool expected = false; + if (!im_lifecycle_started_.compare_exchange_strong(expected, true)) return; + if (xTaskCreate(&Runtime::ImLifecycleTaskEntry, "voicelife_im_lifecycle", 8192, this, 3, &im_lifecycle_task_) != + pdPASS) { + im_lifecycle_started_.store(false); + ESP_LOGW(kTag, "IM_RUNTIME_TASK_FAILED=1"); + } +#else + ESP_LOGI(kTag, "IM_RUNTIME_DISABLED=1"); +#endif +} + +void Runtime::ImLifecycleTaskEntry(void* context) { static_cast(context)->ImLifecycleTask(); } + +void Runtime::ImLifecycleTask() { + im::ImRetryPolicy retry_policy; + while (true) { + Status status = Status::Error(ErrorCode::kUnavailable, "IM Runtime 等待网络"); + im::ImHttpResponse response{.status = im::ImTransportStatus::kNetworkFailure, + .status_code = 0, + .body = {}, + .message = "IM 前置条件未就绪"}; + + if (im_readiness_.NetworkReady() && !im_readiness_.SystemTimeReady()) { + status = SynchronizeSystemTime(); + } + status = im_runtime_.Start(); + if (im_runtime_.state() == im::ImRuntimeState::kProbing) { + response = im_runtime_.ProbeGateway(); + if (im_runtime_.state() != im::ImRuntimeState::kReady) { + status = Status::Error(ErrorCode::kUnavailable, "IM Gateway 认证探针失败"); + } + } + + if (im_runtime_.state() == im::ImRuntimeState::kReady) { + // 选择 #235 的“重启后重新开始”策略:不恢复任何旧会话;下一次 + // 明确语音命令会创建新会话,Gateway 会原子取消同设备旧 pending。 + binding_use_case_.Bind(*im_runtime_.pairing_client(), im_pairing_clock_, im_runtime_.user_id()); + EnqueueBindingReset(binding_use_case_.generation()); + RegisterImPairingAcceptance(im_runtime_.pairing_client(), im_runtime_.device_id(), im_runtime_.user_id()); + ESP_LOGI(kTag, "IM_RUNTIME_READY=1"); + break; + } + if (im_runtime_.state() == im::ImRuntimeState::kDisabled) { + ESP_LOGI(kTag, "IM_RUNTIME_DISABLED=1"); + break; + } + if (im_runtime_.state() == im::ImRuntimeState::kUnconfigured) { + ESP_LOGW(kTag, "IM_RUNTIME_DEGRADED=1 state=%d code=%d", static_cast(im_runtime_.state()), + static_cast(status.code)); + break; + } + + ESP_LOGW(kTag, "IM_RUNTIME_DEGRADED=1 state=%d code=%d http_status=%d", static_cast(im_runtime_.state()), + static_cast(status.code), response.status_code); + const auto delay_ms = retry_policy.NextDelay(response); + if (!delay_ms.has_value()) break; + ESP_LOGI(kTag, "IM_RUNTIME_RETRY attempt=%u delay_ms=%u", static_cast(retry_policy.attempts()), + static_cast(*delay_ms)); + vTaskDelay(pdMS_TO_TICKS(*delay_ms)); + } + vTaskDelete(nullptr); +} +} // namespace voicelife::runtime +#endif diff --git a/components/voicelife_runtime/src/im_binding_mcp_tools.cc b/components/voicelife_runtime_esp/src/im_binding_mcp_tools.cc similarity index 100% rename from components/voicelife_runtime/src/im_binding_mcp_tools.cc rename to components/voicelife_runtime_esp/src/im_binding_mcp_tools.cc diff --git a/components/voicelife_runtime/src/im_binding_mcp_tools.h b/components/voicelife_runtime_esp/src/im_binding_mcp_tools.h similarity index 100% rename from components/voicelife_runtime/src/im_binding_mcp_tools.h rename to components/voicelife_runtime_esp/src/im_binding_mcp_tools.h diff --git a/components/voicelife_runtime/src/im_binding_polling_lease.h b/components/voicelife_runtime_esp/src/im_binding_polling_lease.h similarity index 100% rename from components/voicelife_runtime/src/im_binding_polling_lease.h rename to components/voicelife_runtime_esp/src/im_binding_polling_lease.h diff --git a/components/voicelife_runtime/src/im_binding_presentation.cc b/components/voicelife_runtime_esp/src/im_binding_presentation.cc similarity index 100% rename from components/voicelife_runtime/src/im_binding_presentation.cc rename to components/voicelife_runtime_esp/src/im_binding_presentation.cc diff --git a/components/voicelife_runtime/src/im_binding_presentation.h b/components/voicelife_runtime_esp/src/im_binding_presentation.h similarity index 100% rename from components/voicelife_runtime/src/im_binding_presentation.h rename to components/voicelife_runtime_esp/src/im_binding_presentation.h diff --git a/components/voicelife_runtime/src/im_runtime_bootstrap.cc b/components/voicelife_runtime_esp/src/im_runtime_bootstrap.cc similarity index 100% rename from components/voicelife_runtime/src/im_runtime_bootstrap.cc rename to components/voicelife_runtime_esp/src/im_runtime_bootstrap.cc diff --git a/components/voicelife_runtime/src/im_runtime_bootstrap.h b/components/voicelife_runtime_esp/src/im_runtime_bootstrap.h similarity index 100% rename from components/voicelife_runtime/src/im_runtime_bootstrap.h rename to components/voicelife_runtime_esp/src/im_runtime_bootstrap.h diff --git a/components/voicelife_runtime/src/linx_mcp_bridge.cc b/components/voicelife_runtime_esp/src/linx_mcp_bridge.cc similarity index 100% rename from components/voicelife_runtime/src/linx_mcp_bridge.cc rename to components/voicelife_runtime_esp/src/linx_mcp_bridge.cc diff --git a/components/voicelife_runtime/src/linx_mcp_bridge.h b/components/voicelife_runtime_esp/src/linx_mcp_bridge.h similarity index 100% rename from components/voicelife_runtime/src/linx_mcp_bridge.h rename to components/voicelife_runtime_esp/src/linx_mcp_bridge.h diff --git a/components/voicelife_runtime/src/linx_ota_bootstrap.cc b/components/voicelife_runtime_esp/src/linx_ota_bootstrap.cc similarity index 100% rename from components/voicelife_runtime/src/linx_ota_bootstrap.cc rename to components/voicelife_runtime_esp/src/linx_ota_bootstrap.cc diff --git a/components/voicelife_runtime/src/linx_ota_bootstrap.h b/components/voicelife_runtime_esp/src/linx_ota_bootstrap.h similarity index 100% rename from components/voicelife_runtime/src/linx_ota_bootstrap.h rename to components/voicelife_runtime_esp/src/linx_ota_bootstrap.h diff --git a/components/voicelife_runtime/src/linx_ota_device.inc b/components/voicelife_runtime_esp/src/linx_ota_device.inc similarity index 100% rename from components/voicelife_runtime/src/linx_ota_device.inc rename to components/voicelife_runtime_esp/src/linx_ota_device.inc diff --git a/components/voicelife_runtime/src/mcp_worker_policy.h b/components/voicelife_runtime_esp/src/mcp_worker_policy.h similarity index 100% rename from components/voicelife_runtime/src/mcp_worker_policy.h rename to components/voicelife_runtime_esp/src/mcp_worker_policy.h diff --git a/components/voicelife_runtime/src/wifi_provisioning.cc b/components/voicelife_runtime_esp/src/wifi_provisioning.cc similarity index 100% rename from components/voicelife_runtime/src/wifi_provisioning.cc rename to components/voicelife_runtime_esp/src/wifi_provisioning.cc diff --git a/components/voicelife_runtime/src/wifi_provisioning.h b/components/voicelife_runtime_esp/src/wifi_provisioning.h similarity index 100% rename from components/voicelife_runtime/src/wifi_provisioning.h rename to components/voicelife_runtime_esp/src/wifi_provisioning.h diff --git a/components/voicelife_runtime/src/wifi_provisioning_esp.cc b/components/voicelife_runtime_esp/src/wifi_provisioning_esp.cc similarity index 100% rename from components/voicelife_runtime/src/wifi_provisioning_esp.cc rename to components/voicelife_runtime_esp/src/wifi_provisioning_esp.cc diff --git a/components/voicelife_runtime/src/wifi_provisioning_esp.h b/components/voicelife_runtime_esp/src/wifi_provisioning_esp.h similarity index 100% rename from components/voicelife_runtime/src/wifi_provisioning_esp.h rename to components/voicelife_runtime_esp/src/wifi_provisioning_esp.h diff --git a/scripts/check_architecture.cmake b/scripts/check_architecture.cmake index 18e370f3..6676afd4 100644 --- a/scripts/check_architecture.cmake +++ b/scripts/check_architecture.cmake @@ -93,7 +93,7 @@ endforeach() assert_dependencies(voicelife_contracts PUBLIC) assert_dependencies(voicelife_contracts PRIVATE yyjson) -assert_dependencies(voicelife_application PUBLIC) +assert_dependencies(voicelife_application PUBLIC voicelife_contracts voicelife_voice) assert_dependencies(voicelife_application PRIVATE) assert_dependencies(voicelife_im PUBLIC voicelife_contracts) assert_dependencies(voicelife_im PRIVATE esp_http_client mbedtls) @@ -124,8 +124,8 @@ assert_dependencies(voicelife_audio_esp PRIVATE esp_driver_i2c esp_driver_i2s es assert_dependencies(voicelife_board_esp PUBLIC voicelife_contracts) assert_dependencies(voicelife_board_esp PRIVATE esp_hw_support esp_partition esp_psram esp_system spi_flash) assert_dependencies(voicelife_runtime PUBLIC voicelife_contracts) -assert_dependencies(voicelife_runtime_esp PUBLIC voicelife_application) -assert_dependencies(voicelife_runtime_esp PRIVATE freertos voicelife_mcp) -assert_dependencies(voicelife_runtime PRIVATE esp-tls esp_app_format esp_driver_gpio esp_driver_usb_serial_jtag led_strip esp_event esp_http_client esp_http_server esp_netif lwip esp_partition esp_psram esp_timer esp_wifi nvs_flash nvs_sec_provider spi_flash voicelife_application voicelife_im voicelife_linx voicelife_linx_esp voicelife_mcp voicelife_runtime_esp voicelife_voice voicelife_audio_esp voicelife_display_esp voicelife_schedule voicelife_storage_fatfs voicelife_storage_sqlite) +assert_dependencies(voicelife_runtime PRIVATE voicelife_runtime_esp) +assert_dependencies(voicelife_runtime_esp PUBLIC voicelife_application voicelife_contracts) +assert_dependencies(voicelife_runtime_esp PRIVATE esp-tls esp_app_format esp_driver_gpio esp_driver_usb_serial_jtag led_strip esp_event esp_http_client esp_http_server esp_netif lwip esp_partition esp_psram esp_timer esp_wifi nvs_flash nvs_sec_provider spi_flash voicelife_im voicelife_linx voicelife_linx_esp voicelife_mcp voicelife_voice voicelife_audio_esp voicelife_display_esp voicelife_schedule voicelife_storage_fatfs voicelife_storage_sqlite freertos) message(STATUS "PASS component names, include paths, and dependency graph") diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 0cda0a6c..ba6863ce 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -110,6 +110,7 @@ add_voicelife_library(voice voicelife_voice "${ROOT_DIR}/components/voicelife_voice/src/voice_session.cc" "${ROOT_DIR}/components/voicelife_voice/src/voice_session_coordinator.cc") target_link_libraries(voice PUBLIC contracts) +target_link_libraries(application PUBLIC contracts voice) add_voicelife_library(linx voicelife_linx "${ROOT_DIR}/components/voicelife_linx/src/linx_json_codec.cc" "${ROOT_DIR}/components/voicelife_linx/src/linx_ota.cc" @@ -301,9 +302,9 @@ target_include_directories(schedule_mcp_tools_input_test PRIVATE "${ROOT_DIR}/co target_link_libraries(schedule_mcp_tools_input_test PRIVATE mcp schedule) add_voicelife_test(linx_mcp_bridge_test "unit;mcp;linx;runtime" linx_mcp_bridge_test.cc - "${ROOT_DIR}/components/voicelife_runtime/src/linx_mcp_bridge.cc" + "${ROOT_DIR}/components/voicelife_runtime_esp/src/linx_mcp_bridge.cc" "${ROOT_DIR}/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc") -target_include_directories(linx_mcp_bridge_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime/src") +target_include_directories(linx_mcp_bridge_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime_esp/src") target_link_libraries(linx_mcp_bridge_test PRIVATE contracts mcp schedule) add_voicelife_test(linx_ota_contract_test "unit;linx;contract" linx_ota_contract_test.cc) @@ -419,20 +420,20 @@ add_voicelife_test(binding_use_case_test "unit;im;binding" binding_use_case_test target_link_libraries(binding_use_case_test PRIVATE im contracts Threads::Threads) add_voicelife_test(im_binding_mcp_tools_test "unit;mcp;im;runtime" im_binding_mcp_tools_test.cc - "${ROOT_DIR}/components/voicelife_runtime/src/im_binding_mcp_tools.cc") -target_include_directories(im_binding_mcp_tools_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime/src") + "${ROOT_DIR}/components/voicelife_runtime_esp/src/im_binding_mcp_tools.cc") +target_include_directories(im_binding_mcp_tools_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime_esp/src") target_link_libraries(im_binding_mcp_tools_test PRIVATE mcp im) add_voicelife_test(binding_presentation_test "unit;im;runtime;binding" binding_presentation_test.cc - "${ROOT_DIR}/components/voicelife_runtime/src/im_binding_presentation.cc") -target_include_directories(binding_presentation_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime/src") + "${ROOT_DIR}/components/voicelife_runtime_esp/src/im_binding_presentation.cc") +target_include_directories(binding_presentation_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime_esp/src") target_link_libraries(binding_presentation_test PRIVATE im) add_voicelife_test(binding_polling_lease_test "unit;im;runtime;binding" binding_polling_lease_test.cc) -target_include_directories(binding_polling_lease_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime/src") +target_include_directories(binding_polling_lease_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime_esp/src") add_voicelife_test(mcp_worker_policy_test "unit;mcp;im;runtime" mcp_worker_policy_test.cc) -target_include_directories(mcp_worker_policy_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime/src") +target_include_directories(mcp_worker_policy_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime_esp/src") target_link_libraries(mcp_worker_policy_test PRIVATE im) add_voicelife_test(im_runtime_test "unit;im;runtime" im_runtime_test.cc) @@ -443,9 +444,9 @@ target_link_libraries(runtime_smoke_test PRIVATE mcp voice) add_voicelife_test(wifi_provisioning_test "unit;runtime;wifi;provisioning" wifi_provisioning_test.cc - "${ROOT_DIR}/components/voicelife_runtime/src/wifi_provisioning.cc") + "${ROOT_DIR}/components/voicelife_runtime_esp/src/wifi_provisioning.cc") target_include_directories(wifi_provisioning_test PRIVATE - "${ROOT_DIR}/components/voicelife_runtime/src") + "${ROOT_DIR}/components/voicelife_runtime_esp/src") add_voicelife_test(sqlite_schedule_repository_test "integration;storage;sqlite;schedule" "${ROOT_DIR}/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc") diff --git a/tests/host/interaction_orchestrator_test.cc b/tests/host/interaction_orchestrator_test.cc index d90a20d2..a4c7c095 100644 --- a/tests/host/interaction_orchestrator_test.cc +++ b/tests/host/interaction_orchestrator_test.cc @@ -7,45 +7,79 @@ namespace { using voicelife::application::InteractionAction; -using voicelife::application::InteractionActionKind; using voicelife::application::InteractionActionSink; using voicelife::application::InteractionEvent; -using voicelife::application::InteractionEventKind; using voicelife::application::InteractionOrchestrator; using voicelife::test::Check; +using voicelife::voice::VoiceInteractionAction; +using voicelife::voice::VoiceInteractionEvent; +using voicelife::voice::VoiceInteractionState; class TraceSink final : public InteractionActionSink { public: - void Submit(InteractionAction action) override { trace.push_back(action); } + voicelife::Status Submit(InteractionAction action) override { + trace.push_back(action); + return voicelife::Status::Ok(); + } std::vector trace; }; +void Submit(InteractionOrchestrator& orchestrator, TraceSink& trace, VoiceInteractionEvent event) { + const voicelife::Status result = orchestrator.Handle({.voice_event = event}, trace); + Check(result.ok(), "合法交互事件必须被应用层接受"); +} + } // namespace int main() { - const InteractionOrchestrator orchestrator; + InteractionOrchestrator orchestrator; + TraceSink trace; const std::vector events = { - {.kind = InteractionEventKind::kBootstrapRequested}, - {.kind = InteractionEventKind::kBoardInputArrived}, - {.kind = InteractionEventKind::kVoiceLifecycleChanged}, - {.kind = InteractionEventKind::kConnectivityChanged}, + {.voice_event = VoiceInteractionEvent::kBootCompleted}, + {.voice_event = VoiceInteractionEvent::kWakeDetected}, + {.voice_event = VoiceInteractionEvent::kInterruptAndAcknowledge}, + {.voice_event = VoiceInteractionEvent::kEndpointDetected}, + {.voice_event = VoiceInteractionEvent::kFinalizationTimedOut}, }; const std::vector expected_trace = { - {.kind = InteractionActionKind::kInitializeInteraction}, - {.kind = InteractionActionKind::kDispatchBoardInput}, - {.kind = InteractionActionKind::kDispatchVoiceLifecycle}, - {.kind = InteractionActionKind::kRefreshConnectivity}, + {.source = VoiceInteractionEvent::kBootCompleted, + .state = VoiceInteractionState::kStandby, + .directive = VoiceInteractionAction::kRestoreStandby}, + {.source = VoiceInteractionEvent::kWakeDetected, + .state = VoiceInteractionState::kListening, + .directive = VoiceInteractionAction::kStartVoiceTurn}, + {.source = VoiceInteractionEvent::kInterruptAndAcknowledge, + .state = VoiceInteractionState::kListening, + .directive = VoiceInteractionAction::kInterruptAndStartVoiceTurn}, + {.source = VoiceInteractionEvent::kEndpointDetected, + .state = VoiceInteractionState::kFinalizing, + .directive = VoiceInteractionAction::kStopVoiceTurn}, + {.source = VoiceInteractionEvent::kFinalizationTimedOut, + .state = VoiceInteractionState::kStandby, + .directive = VoiceInteractionAction::kRestoreStandby}, }; - TraceSink first_trace; - TraceSink second_trace; for (const InteractionEvent event : events) { - orchestrator.Handle(event, first_trace); - orchestrator.Handle(event, second_trace); + Submit(orchestrator, trace, event.voice_event); } - Check(first_trace.trace == expected_trace, "编排器必须为固定事件序列生成预期动作轨迹"); - Check(second_trace.trace == first_trace.trace, "相同事件序列必须生成相同动作轨迹"); + Check(trace.trace == expected_trace, "引导、唤醒、打断、超时必须保留状态和动作轨迹"); + Check(orchestrator.state() == VoiceInteractionState::kStandby, "最终 STT 超时后必须恢复待机"); + + InteractionOrchestrator tts_orchestrator; + TraceSink tts_trace; + Submit(tts_orchestrator, tts_trace, VoiceInteractionEvent::kBootCompleted); + Submit(tts_orchestrator, tts_trace, VoiceInteractionEvent::kWakeDetected); + Submit(tts_orchestrator, tts_trace, VoiceInteractionEvent::kIntentReceived); + Submit(tts_orchestrator, tts_trace, VoiceInteractionEvent::kTtsStarted); + Submit(tts_orchestrator, tts_trace, VoiceInteractionEvent::kTtsStopped); + Check(tts_trace.trace.back() == InteractionAction{.source = VoiceInteractionEvent::kTtsStopped, + .state = VoiceInteractionState::kListening, + .directive = VoiceInteractionAction::kStartCapture}, + "TTS 结束后必须恢复 follow-up 聆听"); + + const voicelife::Status rejected = orchestrator.Handle({.voice_event = VoiceInteractionEvent::kTtsStopped}, trace); + Check(!rejected.ok(), "乱序事件必须被拒绝且不产生动作"); return 0; } diff --git a/tests/python/test_im_wifi_credential_isolation.py b/tests/python/test_im_wifi_credential_isolation.py index 83d7b165..0cdcf855 100644 --- a/tests/python/test_im_wifi_credential_isolation.py +++ b/tests/python/test_im_wifi_credential_isolation.py @@ -3,9 +3,10 @@ import unittest ROOT = pathlib.Path(__file__).resolve().parents[2] -WIFI_SOURCE = (ROOT / "components/voicelife_runtime/src/linx_ota_bootstrap.cc").read_text() -IM_SOURCE = (ROOT / "components/voicelife_runtime/src/im_runtime_bootstrap.cc").read_text() -RUNTIME_SOURCE = (ROOT / "components/voicelife_runtime/src/runtime.cc").read_text() +ESP_RUNTIME_ROOT = ROOT / "components/voicelife_runtime_esp/src" +WIFI_SOURCE = (ESP_RUNTIME_ROOT / "linx_ota_bootstrap.cc").read_text() +IM_SOURCE = (ESP_RUNTIME_ROOT / "im_runtime_bootstrap.cc").read_text() +RUNTIME_SOURCE = (ESP_RUNTIME_ROOT / "esp_runtime.cc").read_text() class ImWifiCredentialIsolationTest(unittest.TestCase): @@ -38,8 +39,8 @@ def test_usb_serial_jtag_reads_use_the_driver_api_without_fcntl(self): def test_im_usb_provisioning_starts_before_wifi_bootstrap_can_fail(self): startup = RUNTIME_SOURCE[ - RUNTIME_SOURCE.index("Status Start(PlatformAssembly& assembly)") : RUNTIME_SOURCE.index( - "void StopEventLoop()" + RUNTIME_SOURCE.index("Status Runtime::Start(PlatformAssembly& assembly)") : RUNTIME_SOURCE.index( + "Status Runtime::RequestInterrupt()" ) ] self.assertLess(startup.index("StartImProvisioningTask()"), startup.index("BootstrapLinxOtaConfig(")) From c4512839a548fe4cc5e4a941dea9315da3eef323 Mon Sep 17 00:00:00 2001 From: ZhaoXingPeng <848238014@qq.com> Date: Tue, 18 Aug 2026 15:07:50 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=9B=20fix(runtime):=20=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E4=BA=A4=E4=BA=92=E5=8A=A8=E4=BD=9C=E5=94=A4=E9=86=92?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/interaction_orchestrator.h | 10 ++++++++-- .../src/interaction_orchestrator.cc | 6 ++++-- .../src/esp_runtime_interaction.cc | 12 +++++------ .../src/esp_runtime_internal.h | 1 - tests/host/interaction_orchestrator_test.cc | 20 ++++++++++++------- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h b/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h index 911d0c6b..bb635a5c 100644 --- a/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h +++ b/components/voicelife_application/include/voicelife/application/interaction_orchestrator.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "voicelife/contracts/status.h" #include "voicelife/voice/voice_interaction_controller.h" @@ -8,6 +11,7 @@ namespace voicelife::application { /** @brief 一次交互编排请求的稳定输入模型,不携带 ESP-IDF 或 FreeRTOS 类型。 */ struct InteractionEvent { voice::VoiceInteractionEvent voice_event = voice::VoiceInteractionEvent::kBootCompleted; + std::string_view wake_word; }; /** @brief 一次合法状态迁移产生的、可由平台适配器执行的语义动作。 */ @@ -15,9 +19,11 @@ struct InteractionAction { voice::VoiceInteractionEvent source = voice::VoiceInteractionEvent::kBootCompleted; voice::VoiceInteractionState state = voice::VoiceInteractionState::kBooting; voice::VoiceInteractionAction directive = voice::VoiceInteractionAction::kNone; + std::string wake_word; - friend constexpr bool operator==(InteractionAction lhs, InteractionAction rhs) { - return lhs.source == rhs.source && lhs.state == rhs.state && lhs.directive == rhs.directive; + friend bool operator==(const InteractionAction& lhs, const InteractionAction& rhs) { + return lhs.source == rhs.source && lhs.state == rhs.state && lhs.directive == rhs.directive && + lhs.wake_word == rhs.wake_word; } }; diff --git a/components/voicelife_application/src/interaction_orchestrator.cc b/components/voicelife_application/src/interaction_orchestrator.cc index 27995957..d76d0d55 100644 --- a/components/voicelife_application/src/interaction_orchestrator.cc +++ b/components/voicelife_application/src/interaction_orchestrator.cc @@ -7,8 +7,10 @@ Status InteractionOrchestrator::Handle(InteractionEvent event, InteractionAction if (!transition.ok() || !transition.value.has_value()) { return transition.status; } - return actions.Submit( - {.source = event.voice_event, .state = transition.value->state, .directive = transition.value->action}); + return actions.Submit({.source = event.voice_event, + .state = transition.value->state, + .directive = transition.value->action, + .wake_word = std::string(event.wake_word)}); } voice::VoiceInteractionState InteractionOrchestrator::state() const { return controller_.state(); } diff --git a/components/voicelife_runtime_esp/src/esp_runtime_interaction.cc b/components/voicelife_runtime_esp/src/esp_runtime_interaction.cc index 558fc3ee..2d2a2049 100644 --- a/components/voicelife_runtime_esp/src/esp_runtime_interaction.cc +++ b/components/voicelife_runtime_esp/src/esp_runtime_interaction.cc @@ -130,12 +130,10 @@ void Runtime::ClearExpiredWakeAck() { } Status Runtime::HandleInteractionEvent(voice::VoiceInteractionEvent event, std::string_view wake_word) { - active_wake_word_.assign(wake_word); - const Status status = interaction_task_host_.Submit({.voice_event = event}, *this); + const Status status = interaction_task_host_.Submit({.voice_event = event, .wake_word = wake_word}, *this); if (!status.ok()) { ESP_LOGW(kTag, "忽略乱序板端交互事件=%d: %s", static_cast(event), status.message.c_str()); } - active_wake_word_.clear(); return status; } @@ -231,10 +229,10 @@ Status Runtime::Submit(application::InteractionAction transition) { QueueCaptureStart(); return Status::Ok(); case voice::VoiceInteractionAction::kStartVoiceTurn: - if (active_wake_word_.empty()) { + if (transition.wake_word.empty()) { return Status::Error(ErrorCode::kInvalidArgument, "本地唤醒词不能为空"); } - QueueVoiceTurn(active_wake_word_); + QueueVoiceTurn(transition.wake_word); return Status::Ok(); case voice::VoiceInteractionAction::kStopVoiceTurn: QueueCaptureStop(); @@ -243,10 +241,10 @@ Status Runtime::Submit(application::InteractionAction transition) { QueueInterruptAndCapture(); return Status::Ok(); case voice::VoiceInteractionAction::kInterruptAndStartVoiceTurn: - if (active_wake_word_.empty()) { + if (transition.wake_word.empty()) { return Status::Error(ErrorCode::kInvalidArgument, "本地打断词不能为空"); } - QueueInterruptAndVoiceTurn(active_wake_word_); + QueueInterruptAndVoiceTurn(transition.wake_word); return Status::Ok(); case voice::VoiceInteractionAction::kRestoreStandby: // transport_disconnected 必须停在 kReconnecting;物理唤醒门可恢复, diff --git a/components/voicelife_runtime_esp/src/esp_runtime_internal.h b/components/voicelife_runtime_esp/src/esp_runtime_internal.h index 7dc19f69..f2a992c8 100644 --- a/components/voicelife_runtime_esp/src/esp_runtime_internal.h +++ b/components/voicelife_runtime_esp/src/esp_runtime_internal.h @@ -296,7 +296,6 @@ class Runtime final : public application::InteractionActionSink { #endif application::InteractionOrchestrator interaction_orchestrator_; runtime_esp::EspInteractionTaskHost interaction_task_host_{interaction_orchestrator_}; - std::string active_wake_word_; std::unique_ptr provider_; std::unique_ptr session_; }; diff --git a/tests/host/interaction_orchestrator_test.cc b/tests/host/interaction_orchestrator_test.cc index a4c7c095..a29204c5 100644 --- a/tests/host/interaction_orchestrator_test.cc +++ b/tests/host/interaction_orchestrator_test.cc @@ -1,5 +1,6 @@ #include "voicelife/application/interaction_orchestrator.h" +#include #include #include "support/test_support.h" @@ -25,8 +26,9 @@ class TraceSink final : public InteractionActionSink { std::vector trace; }; -void Submit(InteractionOrchestrator& orchestrator, TraceSink& trace, VoiceInteractionEvent event) { - const voicelife::Status result = orchestrator.Handle({.voice_event = event}, trace); +void Submit(InteractionOrchestrator& orchestrator, TraceSink& trace, VoiceInteractionEvent event, + std::string_view wake_word = {}) { + const voicelife::Status result = orchestrator.Handle({.voice_event = event, .wake_word = wake_word}, trace); Check(result.ok(), "合法交互事件必须被应用层接受"); } @@ -37,8 +39,8 @@ int main() { TraceSink trace; const std::vector events = { {.voice_event = VoiceInteractionEvent::kBootCompleted}, - {.voice_event = VoiceInteractionEvent::kWakeDetected}, - {.voice_event = VoiceInteractionEvent::kInterruptAndAcknowledge}, + {.voice_event = VoiceInteractionEvent::kWakeDetected, .wake_word = "hello"}, + {.voice_event = VoiceInteractionEvent::kInterruptAndAcknowledge, .wake_word = "stop"}, {.voice_event = VoiceInteractionEvent::kEndpointDetected}, {.voice_event = VoiceInteractionEvent::kFinalizationTimedOut}, }; @@ -48,10 +50,12 @@ int main() { .directive = VoiceInteractionAction::kRestoreStandby}, {.source = VoiceInteractionEvent::kWakeDetected, .state = VoiceInteractionState::kListening, - .directive = VoiceInteractionAction::kStartVoiceTurn}, + .directive = VoiceInteractionAction::kStartVoiceTurn, + .wake_word = "hello"}, {.source = VoiceInteractionEvent::kInterruptAndAcknowledge, .state = VoiceInteractionState::kListening, - .directive = VoiceInteractionAction::kInterruptAndStartVoiceTurn}, + .directive = VoiceInteractionAction::kInterruptAndStartVoiceTurn, + .wake_word = "stop"}, {.source = VoiceInteractionEvent::kEndpointDetected, .state = VoiceInteractionState::kFinalizing, .directive = VoiceInteractionAction::kStopVoiceTurn}, @@ -61,10 +65,12 @@ int main() { }; for (const InteractionEvent event : events) { - Submit(orchestrator, trace, event.voice_event); + Submit(orchestrator, trace, event.voice_event, event.wake_word); } Check(trace.trace == expected_trace, "引导、唤醒、打断、超时必须保留状态和动作轨迹"); + Check(trace.trace[1].wake_word == "hello" && trace.trace[2].wake_word == "stop", + "唤醒与打断的动作轨迹必须保留各自的关键参数"); Check(orchestrator.state() == VoiceInteractionState::kStandby, "最终 STT 超时后必须恢复待机"); InteractionOrchestrator tts_orchestrator; From b7031fa1beb3b637bf4fc38be8f3d107b8cbb26f Mon Sep 17 00:00:00 2001 From: ZhaoXingPeng <848238014@qq.com> Date: Tue, 18 Aug 2026 15:33:21 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(runtime):=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=20ESP=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/voicelife_runtime/CMakeLists.txt | 4 +--- components/voicelife_runtime/src/runtime.cc | 11 ---------- .../voicelife_runtime_esp/CMakeLists.txt | 4 ++-- .../voicelife_runtime_esp/src/esp_runtime.cc | 9 ++++---- .../src/esp_runtime_internal.h | 2 -- main/CMakeLists.txt | 2 +- scripts/check_architecture.cmake | 11 +++++++--- tests/host/interaction_orchestrator_test.cc | 21 ++++++++++++------- 8 files changed, 30 insertions(+), 34 deletions(-) delete mode 100644 components/voicelife_runtime/src/runtime.cc diff --git a/components/voicelife_runtime/CMakeLists.txt b/components/voicelife_runtime/CMakeLists.txt index c367d997..c051c4c3 100644 --- a/components/voicelife_runtime/CMakeLists.txt +++ b/components/voicelife_runtime/CMakeLists.txt @@ -1,6 +1,4 @@ idf_component_register( - SRCS "src/runtime.cc" INCLUDE_DIRS "include" - REQUIRES voicelife_contracts - PRIV_REQUIRES voicelife_runtime_esp + REQUIRES voicelife_contracts voicelife_voice ) diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc deleted file mode 100644 index 42dbb0a9..00000000 --- a/components/voicelife_runtime/src/runtime.cc +++ /dev/null @@ -1,11 +0,0 @@ -#include "voicelife/runtime/runtime.h" - -#include "voicelife/runtime_esp/esp_runtime.h" - -namespace voicelife::runtime { - -Status Start(PlatformAssembly& assembly) { return runtime_esp::Start(assembly); } - -Status RequestInterrupt() { return runtime_esp::RequestInterrupt(); } - -} // namespace voicelife::runtime diff --git a/components/voicelife_runtime_esp/CMakeLists.txt b/components/voicelife_runtime_esp/CMakeLists.txt index e01a06b7..d1c47897 100644 --- a/components/voicelife_runtime_esp/CMakeLists.txt +++ b/components/voicelife_runtime_esp/CMakeLists.txt @@ -6,8 +6,8 @@ idf_component_register( "src/wifi_provisioning.cc" "src/wifi_provisioning_esp.cc" "src/im_binding_mcp_tools.cc" "src/im_binding_presentation.cc" INCLUDE_DIRS "include" - PRIV_INCLUDE_DIRS "../voicelife_runtime/include" "src" - REQUIRES voicelife_application voicelife_contracts + PRIV_INCLUDE_DIRS "src" + REQUIRES voicelife_application voicelife_contracts voicelife_runtime PRIV_REQUIRES voicelife_mcp voicelife_voice voicelife_linx voicelife_linx_esp voicelife_audio_esp voicelife_display_esp voicelife_schedule voicelife_im voicelife_storage_fatfs voicelife_storage_sqlite nvs_flash nvs_sec_provider esp_timer esp_http_client esp-tls esp_wifi diff --git a/components/voicelife_runtime_esp/src/esp_runtime.cc b/components/voicelife_runtime_esp/src/esp_runtime.cc index 15962975..9751ea9d 100644 --- a/components/voicelife_runtime_esp/src/esp_runtime.cc +++ b/components/voicelife_runtime_esp/src/esp_runtime.cc @@ -1,4 +1,5 @@ #include "esp_runtime_internal.h" +#include "voicelife/runtime/runtime.h" #ifdef ESP_PLATFORM #include "esp_heap_caps.h" @@ -259,11 +260,11 @@ Runtime& Instance() { return runtime; } -Status StartEspImpl(PlatformAssembly& assembly) { return Instance().Start(assembly); } -Status RequestInterruptEspImpl() { return Instance().RequestInterrupt(); } +Status Start(PlatformAssembly& assembly) { return Instance().Start(assembly); } +Status RequestInterrupt() { return Instance().RequestInterrupt(); } } // namespace voicelife::runtime namespace voicelife::runtime_esp { -Status Start(runtime::PlatformAssembly& assembly) { return runtime::StartEspImpl(assembly); } -Status RequestInterrupt() { return runtime::RequestInterruptEspImpl(); } +Status Start(runtime::PlatformAssembly& assembly) { return runtime::Start(assembly); } +Status RequestInterrupt() { return runtime::RequestInterrupt(); } } // namespace voicelife::runtime_esp diff --git a/components/voicelife_runtime_esp/src/esp_runtime_internal.h b/components/voicelife_runtime_esp/src/esp_runtime_internal.h index f2a992c8..3bf739c9 100644 --- a/components/voicelife_runtime_esp/src/esp_runtime_internal.h +++ b/components/voicelife_runtime_esp/src/esp_runtime_internal.h @@ -301,7 +301,5 @@ class Runtime final : public application::InteractionActionSink { }; Runtime& Instance(); -Status StartEspImpl(PlatformAssembly& assembly); -Status RequestInterruptEspImpl(); } // namespace voicelife::runtime diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 77e1c507..262c884e 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRCS "main.cc" "platform_assemblies.cc" INCLUDE_DIRS "." - PRIV_REQUIRES log voicelife_runtime voicelife_im voicelife_contracts voicelife_voice voicelife_audio_esp voicelife_board_esp voicelife_display_esp voicelife_display_sparkbot esp_driver_gpio esp_timer led_strip + PRIV_REQUIRES log voicelife_runtime voicelife_runtime_esp voicelife_im voicelife_contracts voicelife_voice voicelife_audio_esp voicelife_board_esp voicelife_display_esp voicelife_display_sparkbot esp_driver_gpio esp_timer led_strip ) diff --git a/scripts/check_architecture.cmake b/scripts/check_architecture.cmake index 6676afd4..2c684c3a 100644 --- a/scripts/check_architecture.cmake +++ b/scripts/check_architecture.cmake @@ -38,6 +38,11 @@ function(idf_component_register) endfunction() function(load_dependencies component_name) + file(READ "${VOICELIFE_ROOT}/components/${component_name}/CMakeLists.txt" component_cmake) + if(component_cmake MATCHES "\.\./voicelife_[a-z0-9_]+/include") + message(FATAL_ERROR + "${component_name} must declare a component dependency instead of importing another component's include path") + endif() unset(captured_public) unset(captured_private) include("${VOICELIFE_ROOT}/components/${component_name}/CMakeLists.txt") @@ -123,9 +128,9 @@ assert_dependencies(voicelife_display_sparkbot PRIVATE esp_driver_spi esp_lcd es assert_dependencies(voicelife_audio_esp PRIVATE esp_driver_i2c esp_driver_i2s esp_timer espressif__esp-sr) assert_dependencies(voicelife_board_esp PUBLIC voicelife_contracts) assert_dependencies(voicelife_board_esp PRIVATE esp_hw_support esp_partition esp_psram esp_system spi_flash) -assert_dependencies(voicelife_runtime PUBLIC voicelife_contracts) -assert_dependencies(voicelife_runtime PRIVATE voicelife_runtime_esp) -assert_dependencies(voicelife_runtime_esp PUBLIC voicelife_application voicelife_contracts) +assert_dependencies(voicelife_runtime PUBLIC voicelife_contracts voicelife_voice) +assert_dependencies(voicelife_runtime PRIVATE) +assert_dependencies(voicelife_runtime_esp PUBLIC voicelife_application voicelife_contracts voicelife_runtime) assert_dependencies(voicelife_runtime_esp PRIVATE esp-tls esp_app_format esp_driver_gpio esp_driver_usb_serial_jtag led_strip esp_event esp_http_client esp_http_server esp_netif lwip esp_partition esp_psram esp_timer esp_wifi nvs_flash nvs_sec_provider spi_flash voicelife_im voicelife_linx voicelife_linx_esp voicelife_mcp voicelife_voice voicelife_audio_esp voicelife_display_esp voicelife_schedule voicelife_storage_fatfs voicelife_storage_sqlite freertos) message(STATUS "PASS component names, include paths, and dependency graph") diff --git a/tests/host/interaction_orchestrator_test.cc b/tests/host/interaction_orchestrator_test.cc index a29204c5..50c1264d 100644 --- a/tests/host/interaction_orchestrator_test.cc +++ b/tests/host/interaction_orchestrator_test.cc @@ -38,16 +38,17 @@ int main() { InteractionOrchestrator orchestrator; TraceSink trace; const std::vector events = { - {.voice_event = VoiceInteractionEvent::kBootCompleted}, + {.voice_event = VoiceInteractionEvent::kBootCompleted, .wake_word = {}}, {.voice_event = VoiceInteractionEvent::kWakeDetected, .wake_word = "hello"}, {.voice_event = VoiceInteractionEvent::kInterruptAndAcknowledge, .wake_word = "stop"}, - {.voice_event = VoiceInteractionEvent::kEndpointDetected}, - {.voice_event = VoiceInteractionEvent::kFinalizationTimedOut}, + {.voice_event = VoiceInteractionEvent::kEndpointDetected, .wake_word = {}}, + {.voice_event = VoiceInteractionEvent::kFinalizationTimedOut, .wake_word = {}}, }; const std::vector expected_trace = { {.source = VoiceInteractionEvent::kBootCompleted, .state = VoiceInteractionState::kStandby, - .directive = VoiceInteractionAction::kRestoreStandby}, + .directive = VoiceInteractionAction::kRestoreStandby, + .wake_word = {}}, {.source = VoiceInteractionEvent::kWakeDetected, .state = VoiceInteractionState::kListening, .directive = VoiceInteractionAction::kStartVoiceTurn, @@ -58,10 +59,12 @@ int main() { .wake_word = "stop"}, {.source = VoiceInteractionEvent::kEndpointDetected, .state = VoiceInteractionState::kFinalizing, - .directive = VoiceInteractionAction::kStopVoiceTurn}, + .directive = VoiceInteractionAction::kStopVoiceTurn, + .wake_word = {}}, {.source = VoiceInteractionEvent::kFinalizationTimedOut, .state = VoiceInteractionState::kStandby, - .directive = VoiceInteractionAction::kRestoreStandby}, + .directive = VoiceInteractionAction::kRestoreStandby, + .wake_word = {}}, }; for (const InteractionEvent event : events) { @@ -82,10 +85,12 @@ int main() { Submit(tts_orchestrator, tts_trace, VoiceInteractionEvent::kTtsStopped); Check(tts_trace.trace.back() == InteractionAction{.source = VoiceInteractionEvent::kTtsStopped, .state = VoiceInteractionState::kListening, - .directive = VoiceInteractionAction::kStartCapture}, + .directive = VoiceInteractionAction::kStartCapture, + .wake_word = {}}, "TTS 结束后必须恢复 follow-up 聆听"); - const voicelife::Status rejected = orchestrator.Handle({.voice_event = VoiceInteractionEvent::kTtsStopped}, trace); + const voicelife::Status rejected = + orchestrator.Handle({.voice_event = VoiceInteractionEvent::kTtsStopped, .wake_word = {}}, trace); Check(!rejected.ok(), "乱序事件必须被拒绝且不产生动作"); return 0; } From e5e1802e635235820a5dd5b0718da80593a351d0 Mon Sep 17 00:00:00 2001 From: ZhaoXingPeng <848238014@qq.com> Date: Tue, 18 Aug 2026 15:44:20 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=85=20test(runtime):=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E4=BA=A4=E4=BA=92=E5=8A=A8=E4=BD=9C=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/interaction_orchestrator.cc | 2 +- tests/host/interaction_orchestrator_test.cc | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/components/voicelife_application/src/interaction_orchestrator.cc b/components/voicelife_application/src/interaction_orchestrator.cc index d76d0d55..867b6fef 100644 --- a/components/voicelife_application/src/interaction_orchestrator.cc +++ b/components/voicelife_application/src/interaction_orchestrator.cc @@ -4,7 +4,7 @@ namespace voicelife::application { Status InteractionOrchestrator::Handle(InteractionEvent event, InteractionActionSink& actions) { const auto transition = controller_.Handle(event.voice_event); - if (!transition.ok() || !transition.value.has_value()) { + if (!transition.ok()) { return transition.status; } return actions.Submit({.source = event.voice_event, diff --git a/tests/host/interaction_orchestrator_test.cc b/tests/host/interaction_orchestrator_test.cc index 50c1264d..e2878831 100644 --- a/tests/host/interaction_orchestrator_test.cc +++ b/tests/host/interaction_orchestrator_test.cc @@ -1,6 +1,7 @@ #include "voicelife/application/interaction_orchestrator.h" #include +#include #include #include "support/test_support.h" @@ -26,6 +27,16 @@ class TraceSink final : public InteractionActionSink { std::vector trace; }; +class RejectingSink final : public InteractionActionSink { + public: + voicelife::Status Submit(InteractionAction action) override { + submitted = std::move(action); + return voicelife::Status::Error(voicelife::ErrorCode::kUnavailable, "动作投影不可用"); + } + + InteractionAction submitted; +}; + void Submit(InteractionOrchestrator& orchestrator, TraceSink& trace, VoiceInteractionEvent event, std::string_view wake_word = {}) { const voicelife::Status result = orchestrator.Handle({.voice_event = event, .wake_word = wake_word}, trace); @@ -76,6 +87,41 @@ int main() { "唤醒与打断的动作轨迹必须保留各自的关键参数"); Check(orchestrator.state() == VoiceInteractionState::kStandby, "最终 STT 超时后必须恢复待机"); + const InteractionAction baseline = trace.trace[1]; + Check(!(baseline == InteractionAction{.source = VoiceInteractionEvent::kBootCompleted, + .state = baseline.state, + .directive = baseline.directive, + .wake_word = baseline.wake_word}), + "动作比较必须包含来源事件"); + Check(!(baseline == InteractionAction{.source = baseline.source, + .state = VoiceInteractionState::kStandby, + .directive = baseline.directive, + .wake_word = baseline.wake_word}), + "动作比较必须包含迁移后的状态"); + Check(!(baseline == InteractionAction{.source = baseline.source, + .state = baseline.state, + .directive = VoiceInteractionAction::kStopVoiceTurn, + .wake_word = baseline.wake_word}), + "动作比较必须包含执行指令"); + Check(!(baseline == InteractionAction{.source = baseline.source, + .state = baseline.state, + .directive = baseline.directive, + .wake_word = "different"}), + "动作比较必须包含唤醒参数"); + + InteractionOrchestrator rejecting_orchestrator; + RejectingSink rejecting_sink; + const voicelife::Status projection_failure = rejecting_orchestrator.Handle( + {.voice_event = VoiceInteractionEvent::kBootCompleted, .wake_word = {}}, rejecting_sink); + Check(!projection_failure.ok() && projection_failure.code == voicelife::ErrorCode::kUnavailable, + "动作端口失败必须透传给调用方"); + Check(rejecting_sink.submitted == InteractionAction{.source = VoiceInteractionEvent::kBootCompleted, + .state = VoiceInteractionState::kStandby, + .directive = VoiceInteractionAction::kRestoreStandby, + .wake_word = {}}, + "动作端口失败前仍须收到完整的迁移动作"); + Check(rejecting_orchestrator.state() == VoiceInteractionState::kStandby, "动作端口失败不得回滚既有状态机迁移语义"); + InteractionOrchestrator tts_orchestrator; TraceSink tts_trace; Submit(tts_orchestrator, tts_trace, VoiceInteractionEvent::kBootCompleted);