diff --git a/components/voicelife_display_esp/include/voicelife/display_esp/ssd1306_presentation_adapter.h b/components/voicelife_display_esp/include/voicelife/display_esp/ssd1306_presentation_adapter.h index 8ccf97ce..e6697d1a 100644 --- a/components/voicelife_display_esp/include/voicelife/display_esp/ssd1306_presentation_adapter.h +++ b/components/voicelife_display_esp/include/voicelife/display_esp/ssd1306_presentation_adapter.h @@ -4,6 +4,7 @@ #include #include "voicelife/contracts/status.h" +#include "voicelife/display_esp/ssd1306_status_display.h" #include "voicelife/voice/voice_ports.h" namespace voicelife::display_esp { @@ -19,9 +20,15 @@ namespace voicelife::display_esp { */ class Ssd1306PresentationAdapter : public voicelife::voice::PresentationPort { public: - /** @brief 构造函数。 */ - /** @brief 构造函数。 */ - Ssd1306PresentationAdapter() = default; + /** @brief 显示初始化函数类型;用于把硬件启动路径置于可测边界。 */ + using InitializeFunction = voicelife::Status (*)(); + + /** + * @brief 构造函数。 + * @param initialize 底层 SSD1306 初始化函数。 + */ + explicit Ssd1306PresentationAdapter(InitializeFunction initialize = &InitializeStatusDisplay) + : initialize_(initialize) {} /** @brief 析构函数:释放滚动定时器。 */ ~Ssd1306PresentationAdapter() override; @@ -36,6 +43,12 @@ class Ssd1306PresentationAdapter : public voicelife::voice::PresentationPort { */ [[nodiscard]] const voicelife::voice::DisplayCapabilities& capabilities() const override; + /** + * @brief 初始化 SSD1306 面板,使后续 Render 能真正提交像素。 + * @return 底层面板初始化状态。 + */ + voicelife::Status Start(); + /** * @brief 将显示快照映射为点阵屏文本界面并提交给旧渲染实现。 * @@ -61,6 +74,8 @@ class Ssd1306PresentationAdapter : public voicelife::voice::PresentationPort { [[maybe_unused]] void* scroll_timer_ = nullptr; /** @brief 滚动窗口起始字符。 */ [[maybe_unused]] std::size_t scroll_offset_ = 0; + /** @brief 受控的底层面板初始化入口。 */ + InitializeFunction initialize_; }; } // namespace voicelife::display_esp diff --git a/components/voicelife_display_esp/src/ssd1306_presentation_adapter.cc b/components/voicelife_display_esp/src/ssd1306_presentation_adapter.cc index 0db8d280..15d223b0 100644 --- a/components/voicelife_display_esp/src/ssd1306_presentation_adapter.cc +++ b/components/voicelife_display_esp/src/ssd1306_presentation_adapter.cc @@ -99,6 +99,8 @@ const voicelife::voice::DisplayCapabilities& Ssd1306PresentationAdapter::capabil return kSsd1306Capabilities; } +voicelife::Status Ssd1306PresentationAdapter::Start() { return initialize_(); } + voicelife::Status Ssd1306PresentationAdapter::Render(const voicelife::voice::DisplaySnapshot& snapshot) { std::lock_guard lock(state_mutex_); last_snapshot_ = snapshot; diff --git a/components/voicelife_display_esp/src/ssd1306_status_display.cc b/components/voicelife_display_esp/src/ssd1306_status_display.cc index de117621..95edf765 100644 --- a/components/voicelife_display_esp/src/ssd1306_status_display.cc +++ b/components/voicelife_display_esp/src/ssd1306_status_display.cc @@ -746,7 +746,7 @@ Status InitializeStatusDisplay() { esp_lcd_panel_disp_on_off(state.panel, true) != ESP_OK) { return Status::Error(ErrorCode::kUnavailable, "OLED SSD1306 上电失败"); } - // Match the bread-compact-wifi board orientation used by 小智. + // 实板面板需要双轴镜像,才能使正常装配方向下的文字正向显示。 if (esp_lcd_panel_mirror(state.panel, true, true) != ESP_OK || esp_lcd_panel_invert_color(state.panel, false) != ESP_OK) { return Status::Error(ErrorCode::kUnavailable, "OLED SSD1306 显示方向配置失败"); diff --git a/components/voicelife_im/include/voicelife/im/im_binding_use_case.h b/components/voicelife_im/include/voicelife/im/im_binding_use_case.h index a665fbaf..2c62231c 100644 --- a/components/voicelife_im/include/voicelife/im/im_binding_use_case.h +++ b/components/voicelife_im/include/voicelife/im/im_binding_use_case.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -31,6 +32,10 @@ struct BindingResult { BindingState state = BindingState::kIdle; std::string display_code; std::string expires_at; + /** 创建该会话时请求的有效期;仅供本地呈现,绝不传入外部协议。 */ + int expires_in_minutes = 0; + /** Runtime/配置代次;用于丢弃重绑后迟到的旧会话结果。 */ + uint64_t generation = 0; std::string message; }; @@ -64,11 +69,20 @@ class BindingUseCase { BindingResult Start(int expires_in_minutes = 10); /** @brief 推进一次有限轮询状态机。 @return 最近一次脱敏状态。 */ BindingResult Poll(); + /** + * @brief 轮询任务无法启动时终止指定的待确认会话。 + * @param generation 创建该会话时返回的代次;不匹配时不影响当前会话。 + * @return 终止后的失败结果,或当前会话的稳定状态。 + */ + BindingResult AbortPending(uint64_t generation); /** @brief 当前是否持有待确认会话。 @return active 时为 true。 */ [[nodiscard]] bool active() const; /** @brief 返回最近一次观察到的绑定状态。 @return 稳定业务状态。 */ [[nodiscard]] BindingState state() const; + /** @brief 返回当前 Runtime/会话代次;重绑后旧结果必须被交互层丢弃。 + * @return 当前单调递增的绑定代次。 */ + [[nodiscard]] uint64_t generation() const; private: ImPairingPort* client_ = nullptr; @@ -76,6 +90,8 @@ class BindingUseCase { std::optional user_id_; std::unique_ptr controller_; BindingState state_ = BindingState::kIdle; + int active_expiry_minutes_ = 0; + uint64_t generation_ = 0; mutable std::mutex mutex_; }; diff --git a/components/voicelife_im/include/voicelife/im/im_http_policy.h b/components/voicelife_im/include/voicelife/im/im_http_policy.h new file mode 100644 index 00000000..06f15257 --- /dev/null +++ b/components/voicelife_im/include/voicelife/im/im_http_policy.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace voicelife::im { + +// 单次 IM 网关请求允许占用的最长时间。调用这类请求的上层等待预算必须更长, +// 否则网络操作可能已经成功,上层却先把结果报告为失败。 +inline constexpr uint32_t kImHttpRequestTimeoutMs = 10U * 1000U; + +} // namespace voicelife::im diff --git a/components/voicelife_im/src/im_binding_use_case.cc b/components/voicelife_im/src/im_binding_use_case.cc index 2f92537f..99eb70ad 100644 --- a/components/voicelife_im/src/im_binding_use_case.cc +++ b/components/voicelife_im/src/im_binding_use_case.cc @@ -41,10 +41,12 @@ BindingState Map(PairingFlowStatus status) { return BindingState::kFailed; } -BindingResult Convert(const PairingFlowResult& result) { +BindingResult Convert(const PairingFlowResult& result, int expires_in_minutes, uint64_t generation) { return {.state = Map(result.status), .display_code = result.display_code, .expires_at = result.expires_at, + .expires_in_minutes = expires_in_minutes, + .generation = generation, .message = result.message}; } @@ -58,11 +60,19 @@ void BindingUseCase::Bind(ImPairingPort& client, ImPairingClock& clock, std::opt clock_ = &clock; user_id_ = std::move(user_id); controller_.reset(); + active_expiry_minutes_ = 0; + ++generation_; state_ = BindingState::kIdle; } void BindingUseCase::set_user_id(std::optional user_id) { std::lock_guard lock(mutex_); + if (user_id_ != user_id) { + controller_.reset(); + active_expiry_minutes_ = 0; + ++generation_; + state_ = BindingState::kIdle; + } user_id_ = std::move(user_id); } @@ -70,13 +80,18 @@ BindingResult BindingUseCase::Start(int expires_in_minutes) { std::lock_guard lock(mutex_); if (client_ == nullptr || clock_ == nullptr) { state_ = BindingState::kUnavailable; - return {.state = state_, .display_code = {}, .expires_at = {}, .message = "IM Runtime 尚未 ready"}; + return {.state = state_, + .display_code = {}, + .expires_at = {}, + .generation = generation_, + .message = "IM Runtime 尚未 ready"}; } if (expires_in_minutes < kMinimumExpiryMinutes || expires_in_minutes > kMaximumExpiryMinutes) { // 参数错误不是绑定状态迁移:不改写 state_,直接返回可播报失败。 return {.state = BindingState::kFailed, .display_code = {}, .expires_at = {}, + .generation = generation_, .message = "绑定有效期必须为 1~10 分钟"}; } if (controller_ != nullptr && controller_->active()) { @@ -85,15 +100,27 @@ BindingResult BindingUseCase::Start(int expires_in_minutes) { return {.state = state_, .display_code = controller_->display_code(), .expires_at = controller_->expires_at(), + .expires_in_minutes = active_expiry_minutes_, + .generation = generation_, .message = "已有绑定会话正在进行,请使用当前绑定码"}; } if (!user_id_.has_value() || user_id_->empty()) { state_ = BindingState::kUnavailable; - return {.state = state_, .display_code = {}, .expires_at = {}, .message = "IM 用户引用未配置"}; + return {.state = state_, + .display_code = {}, + .expires_at = {}, + .generation = generation_, + .message = "IM 用户引用未配置"}; } controller_ = std::make_unique(*client_, *clock_); - BindingResult result = Convert(controller_->Begin({.user_id = user_id_, .expires_in_minutes = expires_in_minutes})); + const PairingFlowResult flow_result = + controller_->Begin({.user_id = user_id_, .expires_in_minutes = expires_in_minutes}); + if (flow_result.status == PairingFlowStatus::kPending) { + active_expiry_minutes_ = expires_in_minutes; + ++generation_; + } + BindingResult result = Convert(flow_result, active_expiry_minutes_, generation_); state_ = result.state; return result; } @@ -101,13 +128,29 @@ BindingResult BindingUseCase::Start(int expires_in_minutes) { BindingResult BindingUseCase::Poll() { std::lock_guard lock(mutex_); if (controller_ == nullptr || !controller_->active()) { - return {.state = state_, .display_code = {}, .expires_at = {}, .message = {}}; + return {.state = state_, .display_code = {}, .expires_at = {}, .generation = generation_, .message = {}}; } - BindingResult result = Convert(controller_->Poll()); + BindingResult result = Convert(controller_->Poll(), active_expiry_minutes_, generation_); state_ = result.state; + if (!controller_->active()) active_expiry_minutes_ = 0; return result; } +BindingResult BindingUseCase::AbortPending(uint64_t generation) { + std::lock_guard lock(mutex_); + if (generation != generation_ || controller_ == nullptr || !controller_->active()) { + return {.state = state_, .display_code = {}, .expires_at = {}, .generation = generation_, .message = {}}; + } + controller_.reset(); + active_expiry_minutes_ = 0; + state_ = BindingState::kFailed; + return {.state = state_, + .display_code = {}, + .expires_at = {}, + .generation = generation_, + .message = "绑定轮询任务无法启动"}; +} + bool BindingUseCase::active() const { std::lock_guard lock(mutex_); return controller_ != nullptr && controller_->active(); @@ -118,4 +161,9 @@ BindingState BindingUseCase::state() const { return state_; } +uint64_t BindingUseCase::generation() const { + std::lock_guard lock(mutex_); + return generation_; +} + } // namespace voicelife::im diff --git a/components/voicelife_im/src/transport/esp_http_transport.cc b/components/voicelife_im/src/transport/esp_http_transport.cc index 411b9881..477dee90 100644 --- a/components/voicelife_im/src/transport/esp_http_transport.cc +++ b/components/voicelife_im/src/transport/esp_http_transport.cc @@ -6,21 +6,30 @@ #include #include "esp_crt_bundle.h" +#include "esp_heap_caps.h" #include "esp_http_client.h" #include "esp_log.h" #include "im_response_reader.h" #include "voicelife/im/esp_http_transport_factory.h" #include "voicelife/im/im_endpoint.h" +#include "voicelife/im/im_http_policy.h" namespace voicelife::im { namespace { constexpr char kTag[] = "voicelife_im_http"; -constexpr int kTransportTimeoutMs = 10 * 1000; constexpr size_t kMinimumTransmitBufferBytes = 1024; // 受理结果响应体上限:防止恶意网关回灌无界响应耗尽设备堆内存。 constexpr size_t kMaxResponseBodyBytes = 64 * 1024; +void LogHttpHeap(std::string_view phase) { + ESP_LOGI(kTag, "IM_HTTP_HEAP phase=%.*s internal_free=%u internal_largest=%u psram_free=%u", + static_cast(phase.size()), phase.data(), + 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_http_client 适配为 ImResponseReader,供 ReadResponseBody 判定读取完整性。 class EspResponseReader : public ImResponseReader { public: @@ -63,7 +72,7 @@ ImHttpResponse EspHttpTransport::Perform(const ImHttpRequest& request, esp_http_ esp_http_client_config_t config = {}; config.url = url.c_str(); config.method = method; - config.timeout_ms = kTransportTimeoutMs; + config.timeout_ms = static_cast(kImHttpRequestTimeoutMs); // GET 没有 body,但仍需容纳 URL、Bearer 头与 esp_http_client 生成的请求头。 // 保留固定下限,POST 则按受控请求体继续扩展。 config.buffer_size_tx = std::max(kMinimumTransmitBufferBytes, request.body.size() + 32); @@ -75,6 +84,7 @@ ImHttpResponse EspHttpTransport::Perform(const ImHttpRequest& request, esp_http_ // 通过系统证书 bundle 校验网关证书;若网关使用私有 CA,可改用 config.cert_pem 注入根证书。 config.crt_bundle_attach = esp_crt_bundle_attach; + LogHttpHeap("init"); esp_http_client_handle_t client = esp_http_client_init(&config); if (client == nullptr) { result.status = ImTransportStatus::kNetworkFailure; @@ -110,6 +120,7 @@ ImHttpResponse EspHttpTransport::Perform(const ImHttpRequest& request, esp_http_ // fetch_headers() 只消费响应头(响应体仍留在传输层,由 read() 逐块取回)。 const esp_err_t open_err = esp_http_client_open(client, static_cast(request.body.size())); if (open_err != ESP_OK) { + LogHttpHeap("open_failed"); result.status_code = esp_http_client_get_status_code(client); if (result.status_code == 401 || result.status_code == 403) { result.status = ImTransportStatus::kCredentialRejected; diff --git a/components/voicelife_linx_esp/include/voicelife/linx_esp/esp_websocket_transport.h b/components/voicelife_linx_esp/include/voicelife/linx_esp/esp_websocket_transport.h index 585e028c..a5a96cbe 100644 --- a/components/voicelife_linx_esp/include/voicelife/linx_esp/esp_websocket_transport.h +++ b/components/voicelife_linx_esp/include/voicelife/linx_esp/esp_websocket_transport.h @@ -33,7 +33,8 @@ enum class TransportState { /** 配置 ESP WebSocket 传输的容量、超时和安全策略。 */ struct EspWebSocketTransportOptions { - size_t max_message_bytes = 16 * 1024; + // 上限只在分片重组时按实际消息长度占用;64 KiB 可容纳较长的下行控制/文本帧。 + size_t max_message_bytes = 64 * 1024; // A single envelope owns up to 4 KiB of frame data. 32 entries absorb // short STT/TTS bursts without allowing unbounded protocol backlog. size_t event_queue_capacity = 32; diff --git a/components/voicelife_runtime/CMakeLists.txt b/components/voicelife_runtime/CMakeLists.txt index a36e62af..2900bcc0 100644 --- a/components/voicelife_runtime/CMakeLists.txt +++ b/components/voicelife_runtime/CMakeLists.txt @@ -1,7 +1,7 @@ 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/schedule_mcp_tools.cc" - "src/im_binding_mcp_tools.cc" + "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 diff --git a/components/voicelife_runtime/src/im_binding_mcp_tools.cc b/components/voicelife_runtime/src/im_binding_mcp_tools.cc index 866f4958..e96181ca 100644 --- a/components/voicelife_runtime/src/im_binding_mcp_tools.cc +++ b/components/voicelife_runtime/src/im_binding_mcp_tools.cc @@ -120,18 +120,19 @@ const char* BindingStatusName(im::BindingState state) { return "failed"; } -Status RegisterImBindingMcpTools(mcp::McpServer& server, im::BindingUseCase& use_case, - BindingSessionStartedHook on_session_started) { +Status RegisterImBindingMcpTools(mcp::McpServer& server, im::BindingUseCase& use_case, BindingResultHook on_result) { return server.add_tool( "im.binding.start", "创建 IM 平台绑定会话并返回六位绑定码;用户须在公众号发送「绑定 <六位码>」完成设备绑定,例如:绑定 123456。", mcp::PropertyList({mcp::Property::WithIntegerRange("expires_in_minutes", 1, 10, int64_t{10})}), - [&use_case, on_session_started = std::move(on_session_started)](const mcp::PropertyList& properties) { + [&use_case, on_result = std::move(on_result)](const mcp::PropertyList& properties) { // 越界参数已被 MCP 边界按 Schema(1~10)拒绝;此处 int64→int 转换安全。 const int expires_in_minutes = static_cast(properties.value("expires_in_minutes").value_or(10)); const im::BindingResult result = use_case.Start(expires_in_minutes); - if (result.state == im::BindingState::kPending && on_session_started) on_session_started(); + // 每次语音命令都把脱敏结果交给 Runtime:already_active 可以恢复被普通 + // 对话覆盖的绑定码,创建失败也必须在设备侧给出确定反馈。 + if (on_result) on_result(result); ToolResult output{.status = Status::Ok(), .output = {}}; output.output["status"] = BindingStatusName(result.state); output.output["reason"] = BindingReasonCode(result.state); diff --git a/components/voicelife_runtime/src/im_binding_mcp_tools.h b/components/voicelife_runtime/src/im_binding_mcp_tools.h index e6142d64..9f825714 100644 --- a/components/voicelife_runtime/src/im_binding_mcp_tools.h +++ b/components/voicelife_runtime/src/im_binding_mcp_tools.h @@ -18,18 +18,17 @@ namespace voicelife::runtime { /** @brief 绑定状态 → 稳定机器可读名称(pending/confirmed/expired/...)。 */ const char* BindingStatusName(im::BindingState state); -/// 绑定会话创建成功(pending)后的回调;Runtime 借此启动有界后台轮询, -/// 轮询到 confirmed/expired/cancelled 等终态后释放会话。 -using BindingSessionStartedHook = std::function; +/// 每次 Start 的脱敏结果回调;Runtime 据此投递设备呈现语义,并仅对 pending 启动轮询。 +using BindingResultHook = std::function; /** * @brief 向 MCP Server 注册 IM 平台绑定工具 im.binding.start。 * @param server 目标 MCP Server。 * @param use_case 绑定用例;Start/Poll 与 Runtime 任务并发调用,内部已加锁。 - * @param on_session_started 会话创建成功后的钩子;未提供时仅返回结果、不启动轮询。 + * @param on_result Start 结果钩子;未提供时仅返回 MCP 结果、不启动轮询或设备呈现。 * @return 注册结果。 */ Status RegisterImBindingMcpTools(mcp::McpServer& server, im::BindingUseCase& use_case, - BindingSessionStartedHook on_session_started = {}); + BindingResultHook on_result = {}); } // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/im_binding_polling_lease.h b/components/voicelife_runtime/src/im_binding_polling_lease.h new file mode 100644 index 00000000..d10a4464 --- /dev/null +++ b/components/voicelife_runtime/src/im_binding_polling_lease.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace voicelife::runtime { + +/** + * 绑定轮询任务的代次所有权。 + * 一个仍在退出中的旧任务可以接管新会话,且只能释放它仍持有的代次。 + */ +class BindingPollingLease { + public: + /** @brief 获取或移交轮询所有权。 @return true 时调用方须创建新任务。 */ + bool Acquire(uint64_t generation) { + uint64_t observed = generation_.load(std::memory_order_acquire); + while (true) { + if (observed == generation) return false; + if (generation_.compare_exchange_weak(observed, generation, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return observed == 0; + } + } + } + + /** @brief 仅在调用方仍持有该代次时释放轮询所有权。 */ + bool Release(uint64_t generation) { + uint64_t expected = generation; + return generation_.compare_exchange_strong(expected, 0, std::memory_order_acq_rel, std::memory_order_acquire); + } + + /** @brief 返回当前轮询任务负责的代次。 */ + [[nodiscard]] uint64_t generation() const { return generation_.load(std::memory_order_acquire); } + + private: + std::atomic generation_{0}; +}; + +} // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/im_binding_presentation.cc b/components/voicelife_runtime/src/im_binding_presentation.cc new file mode 100644 index 00000000..41235c0f --- /dev/null +++ b/components/voicelife_runtime/src/im_binding_presentation.cc @@ -0,0 +1,73 @@ +#include "im_binding_presentation.h" + +#include +#include + +namespace voicelife::runtime { +namespace { + +std::string ExpiryText(int minutes) { return minutes > 0 ? std::to_string(minutes) + "分钟内有效" : "请尽快完成"; } + +BindingPresentation TerminalPresentation(std::string status, std::string content, std::string speech, + bool resume_listening = false) { + return {.keep_visible = false, + .announce = true, + .display_duration_ms = kBindingTerminalDisplayDurationMs, + .resume_listening = resume_listening, + .status_text = std::move(status), + .content_text = std::move(content), + .speech_text = std::move(speech)}; +} + +BindingPresentation CodePresentation(const im::BindingResult& result, bool announce) { + if (result.display_code.empty()) return {}; + BindingPresentation presentation{ + .keep_visible = true, + .announce = announce, + .status_text = ExpiryText(result.expires_in_minutes), + .content_text = "绑定 " + result.display_code, + .speech_text = {}, + }; + if (announce) presentation.speech_text = "请在微信公众号发送:绑定 " + result.display_code; + return presentation; +} + +} // namespace + +BindingPresentation PresentBindingResult(const im::BindingResult& result) { + switch (result.state) { + case im::BindingState::kPending: + return CodePresentation(result, true); + case im::BindingState::kAlreadyActive: + return CodePresentation(result, false); + case im::BindingState::kConfirmed: + return TerminalPresentation("公众号绑定", "绑定成功", "微信公众号绑定成功", true); + case im::BindingState::kExpired: + return TerminalPresentation("公众号绑定", "绑定已过期", "绑定已过期,请重新获取绑定码"); + case im::BindingState::kCancelled: + return TerminalPresentation("公众号绑定", "绑定已取消", "绑定已取消,请重新获取绑定码"); + case im::BindingState::kTimedOut: + return TerminalPresentation("公众号绑定", "等待超时", "等待确认超时,请重新获取绑定码"); + case im::BindingState::kUnavailable: + return TerminalPresentation("公众号绑定", "暂不可用", "绑定功能暂不可用,请稍后再试"); + case im::BindingState::kCredentialRejected: + return TerminalPresentation("公众号绑定", "设备凭据无效", "设备凭据无效,无法完成绑定"); + case im::BindingState::kNotFound: + return TerminalPresentation("公众号绑定", "会话不存在", "绑定会话不存在,请重新获取绑定码"); + case im::BindingState::kFailed: + return TerminalPresentation("公众号绑定", "绑定失败", "绑定失败,请稍后再试"); + default: + return {}; + } +} + +bool IsCurrentBindingResult(const im::BindingResult& result, uint64_t current_generation) { + return result.generation == current_generation; +} + +bool ShouldEndVoiceTurnAfterBindingResult(const im::BindingResult& result, bool active_voice_turn) { + return active_voice_turn && + (result.state == im::BindingState::kPending || result.state == im::BindingState::kAlreadyActive); +} + +} // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/im_binding_presentation.h b/components/voicelife_runtime/src/im_binding_presentation.h new file mode 100644 index 00000000..64ac8125 --- /dev/null +++ b/components/voicelife_runtime/src/im_binding_presentation.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#include "voicelife/im/im_binding_use_case.h" + +namespace voicelife::runtime { + +/// BoardRequest 为绑定系统播报预留的 UTF-8 字节数(含结尾空字符)。 +constexpr std::size_t kBindingSystemSpeechCapacity = 96; +/// 绑定成功/失败等终态在 OLED 上的固定可见时长;不得依赖 TTS 完成事件清理。 +constexpr uint32_t kBindingTerminalDisplayDurationMs = 3000; + +/** 绑定状态映射出的纯用户呈现语义,不包含任何显示或语音硬件句柄。 */ +struct BindingPresentation { + /** true 时绑定码界面应在普通语音回合结束后恢复。 */ + bool keep_visible = false; + /** true 时仅请求一次系统播报。 */ + bool announce = false; + /** 大于零时终态页面到期后应独立退场,不依赖语音链路。 */ + uint32_t display_duration_ms = 0; + /** true 时终态页面退场后开始采集,进入后续聆听。 */ + bool resume_listening = false; + std::string status_text; + std::string content_text; + std::string speech_text; +}; + +/** + * 将脱敏绑定结果转换为固定 OLED/TTS 文案。 + * 中间轮询状态刻意不产生输出,避免高频刷新和重复播报。 + */ +BindingPresentation PresentBindingResult(const im::BindingResult& result); + +/** @brief 仅当前 Runtime/会话代次的结果才允许进入设备呈现。 */ +bool IsCurrentBindingResult(const im::BindingResult& result, uint64_t current_generation); + +/** @brief 活跃语音回合返回绑定码后,播报结束应直接回待机,不进入 follow-up 聆听。 */ +bool ShouldEndVoiceTurnAfterBindingResult(const im::BindingResult& result, bool active_voice_turn); + +} // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/linx_mcp_bridge.cc b/components/voicelife_runtime/src/linx_mcp_bridge.cc index a8dbd236..1386fa42 100644 --- a/components/voicelife_runtime/src/linx_mcp_bridge.cc +++ b/components/voicelife_runtime/src/linx_mcp_bridge.cc @@ -10,6 +10,9 @@ namespace voicelife::runtime { namespace { +constexpr std::string_view kBindingToolHandledSummary = "绑定操作已处理"; +constexpr std::string_view kBindingToolFailedSummary = "绑定操作失败"; + std::string Escape(std::string_view value) { std::string result; result.reserve(value.size() + 2); @@ -101,6 +104,9 @@ std::string ToolOutcomeSummary(std::string_view request_payload, bool success) { if (name == nullptr || !name->IsString()) return success ? "操作已完成" : "操作失败"; if (name->string == "schedule.create") return success ? "日程已创建" : "日程创建失败"; if (name->string == "schedule.query") return success ? "日程查询完成" : "日程查询失败"; + if (name->string == "im.binding.start") { + return std::string(success ? kBindingToolHandledSummary : kBindingToolFailedSummary); + } return success ? "操作已完成" : "操作失败"; } @@ -247,4 +253,8 @@ LinxMcpToolOutcome InspectLinxMcpToolOutcome(std::string_view request_payload, c return outcome; } +bool IsBindingMcpToolSummary(std::string_view summary) { + return summary == kBindingToolHandledSummary || summary == kBindingToolFailedSummary; +} + } // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/linx_mcp_bridge.h b/components/voicelife_runtime/src/linx_mcp_bridge.h index 1b9c8a3e..0c1fea82 100644 --- a/components/voicelife_runtime/src/linx_mcp_bridge.h +++ b/components/voicelife_runtime/src/linx_mcp_bridge.h @@ -14,7 +14,7 @@ namespace voicelife::runtime { /** @brief 已解析的 MCP tools/call 用户可见语义结果。 */ struct LinxMcpToolOutcome { bool success = false; - std::string summary = "日程操作失败"; + std::string summary = "操作失败"; }; /** @brief 处理 Linx MCP JSON-RPC payload,并返回带会话标识的 type=mcp 响应。 */ @@ -40,4 +40,7 @@ Result BuildLinxMcpUnavailableResponse(std::string_view payload, st */ LinxMcpToolOutcome InspectLinxMcpToolOutcome(std::string_view request_payload, const Result& response); +/** @brief 绑定工具已有独立 OLED 呈现,通用 MCP 结果层不得再覆盖它。 */ +bool IsBindingMcpToolSummary(std::string_view summary); + } // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/mcp_worker_policy.h b/components/voicelife_runtime/src/mcp_worker_policy.h new file mode 100644 index 00000000..c34871cb --- /dev/null +++ b/components/voicelife_runtime/src/mcp_worker_policy.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "voicelife/im/im_http_policy.h" + +namespace voicelife::runtime { + +// MCP worker 会同步执行 IM HTTP 请求,因此响应等待时间必须覆盖完整网络超时, +// 并给请求收尾、结果序列化和任务调度留出余量。 +inline constexpr uint32_t kMcpResponseGraceMs = 2U * 1000U; +inline constexpr uint32_t kMcpResponseTimeoutMs = im::kImHttpRequestTimeoutMs + kMcpResponseGraceMs; + +static_assert(kMcpResponseTimeoutMs > im::kImHttpRequestTimeoutMs, + "MCP response timeout must exceed the IM HTTP request timeout"); + +} // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc index b4f3b419..48d9eb90 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -19,6 +19,7 @@ #include #include +#include "esp_heap_caps.h" #include "esp_log.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" @@ -40,9 +41,12 @@ #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 "schedule_mcp_tools.h" #include "voicelife/voice/display_snapshot.h" #include "voicelife/voice/voice_interaction_controller.h" @@ -164,8 +168,12 @@ class Runtime final { #ifdef ESP_PLATFORM init_status_ = RegisterScheduleMcpTools(mcp_server_, schedule_service_); if (init_status_.ok()) { - // 会话创建成功(pending)后启动有界后台轮询,轮询到终态释放会话。 - init_status_ = RegisterImBindingMcpTools(mcp_server_, binding_use_case_, [this] { StartBindingPolling(); }); + // 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=3 names=schedule.create,schedule.query,im.binding.start"); @@ -339,7 +347,6 @@ class Runtime final { }; static constexpr std::size_t kMcpWorkerQueueCapacity = 4; - static constexpr uint32_t kMcpResponseTimeoutMs = 3000; Status StartMcpWorker() { std::lock_guard lock(mcp_mutex_); @@ -389,39 +396,44 @@ class Runtime final { // 需以真机 uxTaskGetStackHighWaterMark 实测校准(任务退出时已上报高水位)。 static constexpr uint32_t kBindingPollStackBytes = 16384; - void StartBindingPolling() { - bool expected = false; - if (!binding_poll_started_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { - ESP_LOGW(kTag, "IM_BINDING_POLL_ALREADY_RUNNING=1"); + 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) { - binding_poll_started_.store(false, std::memory_order_release); + 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=1"); + 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; } - // 终态或会话已释放。Start/Poll 由同一把锁串行化:若竞态窗口内新会话 - // 已由 Start 建立(active 再次为真),继续轮询新会话;否则复位标志退出, - // 下一次 Start 的 hook 会重新拉起本任务。 + // 轮询任务只投递脱敏语义结果。事件循环按 BindingUseCase generation + // 丢弃 origin/凭据变更后迟到的旧 confirmed,绝不直接访问显示或语音硬件。 + EnqueueBindingResult(result); + // 终态或会话已释放。若新 Start 在旧任务退出窗口接管租约,Release + // 会失败,本任务继续服务新会话,避免出现 pending 却没有轮询任务。 if (binding_use_case_.active()) continue; - ESP_LOGI(kTag, "IM_BINDING_STATUS=%s stack_high_water=%u", BindingStatusName(result.state), - static_cast(uxTaskGetStackHighWaterMark(nullptr))); - binding_poll_started_.store(false, std::memory_order_release); - break; + 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); @@ -544,7 +556,10 @@ class Runtime final { } 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_.user_id()); ESP_LOGI(kTag, "IM_RUNTIME_READY=1"); break; @@ -586,7 +601,7 @@ class Runtime final { /** 物理唤醒门已就绪后是否需将 Controller 收口为 standby。 */ bool settle_controller = true; /** 当存在时,以 Provider 的正式 TTS 请求播报这段系统话术。 */ - char system_speech[48]; + char system_speech[kBindingSystemSpeechCapacity]; }; void EnqueueBoardInput(BoardInputAction action) { @@ -664,15 +679,21 @@ class Runtime final { (void)xQueueSend(wake_queue_, &recovery, 0); } - void QueueSystemSpeech(std::string_view text) { - if (wake_queue_ == nullptr || text.empty()) return; + 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; - const std::size_t size = - text.size() < sizeof(request.system_speech) - 1 ? text.size() : sizeof(request.system_speech) - 1; - std::memcpy(request.system_speech, text.data(), size); - request.system_speech[size] = '\0'; - (void)xQueueSend(wake_queue_, &request, 0); + 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)。 @@ -1016,6 +1037,16 @@ class Runtime final { 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; @@ -1059,6 +1090,22 @@ class Runtime final { } } + // “收到!”是唤醒确认的短暂显示。即使服务端暂时没有后续语音事件, + // 也必须由事件循环在租约到期后主动刷新,否则 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()) { @@ -1075,6 +1122,7 @@ class Runtime final { // 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: @@ -1082,6 +1130,7 @@ class Runtime final { // 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; @@ -1089,15 +1138,12 @@ class Runtime final { // 会话阶段 → 显示模型快照:状态栏文本 + 表情由阶段派生。 snapshot_.phase = interaction_.state(); snapshot_.mood = PhaseMood(snapshot_.phase); - // 空闲态显示当前时间(若服务端时间已初始化,约 2020 年后),否则显示状态词。 - const time_t now = time(nullptr); - const bool clock_synced = now > 1600000000; // 2020-09-13 之后的真实时间 - if (snapshot_.phase == voice::VoiceInteractionState::kStandby && clock_synced) { - 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); - snapshot_.status_text = clock_text; + 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); } @@ -1127,10 +1173,28 @@ class Runtime final { 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(); @@ -1181,6 +1245,10 @@ class Runtime final { 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)); @@ -1256,20 +1324,30 @@ class Runtime final { } } 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 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, success ? "日程结果" : "日程错误", - summary); + ShowOverlay(success ? voice::VoiceMood::kHappy : voice::VoiceMood::kSad, status, summary); StartOverlayTimer(2500); } else if (evidence.event == "tts_started") { CancelListenTimer(); @@ -1306,11 +1384,12 @@ class Runtime final { ESP_LOGI(kTag, "TTS_STOPPED_STALE state=%d 丢弃迟到结束事件", static_cast(interaction_.state())); return; } - if (terminal_turn_) { - // 终止回合(再见/拜拜):告别播报完成走状态机 kFarewellCompleted - // (kSpeaking→kStandby)恢复待机,不直接 QueueStandbyRecovery。 + if (terminal_turn_ || binding_turn_awaiting_tts_completion_) { + // 告别或绑定码播报完成后直接恢复待机。绑定码页面会在 + // HandleInteractionEvent 的待机呈现规则中立即恢复。 terminal_turn_ = false; - (void)EnqueueEvent(voice::VoiceInteractionEvent::kFarewellCompleted); + binding_turn_awaiting_tts_completion_ = false; + (void)EnqueueEvent(voice::VoiceInteractionEvent::kTerminalResponseCompleted); } else { // 事件化:kTtsStopped 由事件循环唯一执行状态迁移。 EnqueueEvent(voice::VoiceInteractionEvent::kTtsStopped); @@ -1356,7 +1435,13 @@ class Runtime final { [](const std::string& origin) { return im::CreateEspHttpTransport(origin); }}; EspPairingClock im_pairing_clock_; im::BindingUseCase binding_use_case_; - std::atomic_bool binding_poll_started_{false}; + 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_; @@ -1389,6 +1474,12 @@ class Runtime final { /** 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; /** 启动/网络回调携带的受控连接事实。 */ @@ -1460,6 +1551,145 @@ class Runtime final { 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; @@ -1511,7 +1741,9 @@ class Runtime final { break; } if (event_queue_.empty()) { - // 超时轮询:处理音量 overlay 到期恢复(不依赖 timer 直接提交)。 + // 超时轮询:处理短暂显示的到期刷新(不依赖 timer 直接提交)。 + ClearExpiredWakeAck(); + ClearExpiredBindingTerminalDisplay(); if (overlay_expired_.exchange(false)) { if (overlay_active_) { snapshot_ = overlay_base_snapshot_; @@ -1525,6 +1757,8 @@ class Runtime final { 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()) { @@ -1571,12 +1805,42 @@ class Runtime final { 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) { - // 聆听总时限表示没有有效端点/回复,不应再伪造 PressUp - // 进入最终 STT 等待;中止本轮即可确保本地唤醒门重新可用。 - ESP_LOGI(kTag, "LISTEN_TIMEOUT transition=listening->interrupting"); - (void)HandleInteractionEvent(voice::VoiceInteractionEvent::kInterruptRequested); + // 实机麦克风底噪可能让本地 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(); @@ -1640,6 +1904,14 @@ class Runtime final { // 下行内容滚动窗口起始字符(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; diff --git a/components/voicelife_voice/include/voicelife/voice/voice_interaction_controller.h b/components/voicelife_voice/include/voicelife/voice/voice_interaction_controller.h index 76535229..6782348b 100644 --- a/components/voicelife_voice/include/voicelife/voice/voice_interaction_controller.h +++ b/components/voicelife_voice/include/voicelife/voice/voice_interaction_controller.h @@ -25,8 +25,8 @@ enum class VoiceInteractionEvent { kEndpointDetected, /** 最终 STT 超时:kFinalizing → kStandby,中止残留服务端回合并恢复待机。 */ kFinalizationTimedOut, - /** 告别(再见/拜拜)回复播报完成:kSpeaking → kStandby,恢复待机。 */ - kFarewellCompleted, + /** 无需 follow-up 的终结型回复播报完成:kSpeaking → kStandby,恢复待机。 */ + kTerminalResponseCompleted, kIntentReceived, kTtsStarted, kTtsStopped, diff --git a/components/voicelife_voice/src/voice_interaction_controller.cc b/components/voicelife_voice/src/voice_interaction_controller.cc index 9f5c574e..2b5e6614 100644 --- a/components/voicelife_voice/src/voice_interaction_controller.cc +++ b/components/voicelife_voice/src/voice_interaction_controller.cc @@ -95,8 +95,8 @@ Result VoiceInteractionController::Handle(VoiceInter state_ = VoiceInteractionState::kStandby; transition.action = VoiceInteractionAction::kRestoreStandby; break; - case VoiceInteractionEvent::kFarewellCompleted: - // 告别回复播报完成:kSpeaking → kStandby,恢复待机。 + case VoiceInteractionEvent::kTerminalResponseCompleted: + // 告别、绑定码等终结型回复播报完成:不进入 follow-up,直接恢复待机。 if (state_ != VoiceInteractionState::kSpeaking) return InvalidTransition(state_, event); state_ = VoiceInteractionState::kStandby; transition.action = VoiceInteractionAction::kRestoreStandby; diff --git a/main/platform_assemblies.cc b/main/platform_assemblies.cc index 62d041ce..e5543efb 100644 --- a/main/platform_assemblies.cc +++ b/main/platform_assemblies.cc @@ -51,6 +51,8 @@ VoiceLifePcbAssembly::VoiceLifePcbAssembly() : audio_ports_(audio_esp::VoiceLife voicelife::voice::PresentationPort& VoiceLifePcbAssembly::presentation() { return ssd1306_adapter_; } +voicelife::Status VoiceLifePcbAssembly::Start() { return ssd1306_adapter_.Start(); } + void VoiceLifePcbAssembly::BoardInputTaskEntry(void* context) { static_cast(context)->BoardInputTask(); } diff --git a/main/platform_assemblies.h b/main/platform_assemblies.h index ce5fee58..d66945c3 100644 --- a/main/platform_assemblies.h +++ b/main/platform_assemblies.h @@ -30,6 +30,12 @@ class VoiceLifePcbAssembly : public PlatformAssembly { /** @brief 返回点阵显示端口。 @return Ssd1306PresentationAdapter。 */ voicelife::voice::PresentationPort& presentation() override; + /** + * @brief 初始化 PCB SSD1306 面板。 + * @return 面板初始化状态。 + */ + voicelife::Status Start() override; + /** @brief 启动 PCB 物理输入到语义事件的映射。 */ voicelife::Status StartBoardInput(BoardInputSink sink) override; diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 0884ee31..42d13869 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -5,3 +5,7 @@ CONFIG_LOG_DEFAULT_LEVEL_INFO=y CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_voicelife_16mb.csv" +# TLS 的 16 KiB 接收片段无法在语音/Wi-Fi 并发后的碎片化内部堆中稳定分配。 +# 默认分配策略会让大缓冲使用已启用的 PSRAM,较小的会话状态仍遵循系统内部堆阈值。 +# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set +CONFIG_MBEDTLS_DEFAULT_MEM_ALLOC=y diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index b3799966..1adb0b02 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -140,7 +140,8 @@ add_voicelife_library(board_esp voicelife_board_esp ) target_link_libraries(board_esp PUBLIC contracts) add_voicelife_library(display_esp voicelife_display_esp - "${ROOT_DIR}/components/voicelife_display_esp/src/ssd1306_presentation_adapter.cc") + "${ROOT_DIR}/components/voicelife_display_esp/src/ssd1306_presentation_adapter.cc" + "${ROOT_DIR}/components/voicelife_display_esp/src/ssd1306_status_display.cc") target_link_libraries(display_esp PUBLIC contracts voice) add_voicelife_library(display_sparkbot voicelife_display_sparkbot "${ROOT_DIR}/components/voicelife_display_sparkbot/src/sparkbot_lvgl_display.cc" @@ -356,6 +357,18 @@ add_voicelife_test(im_binding_mcp_tools_test "unit;mcp;im;runtime" im_binding_mc target_include_directories(im_binding_mcp_tools_test PRIVATE "${ROOT_DIR}/components/voicelife_runtime/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") +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") + +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_link_libraries(mcp_worker_policy_test PRIVATE im) + add_voicelife_test(im_runtime_test "unit;im;runtime" im_runtime_test.cc) target_link_libraries(im_runtime_test PRIVATE im contracts) diff --git a/tests/host/binding_polling_lease_test.cc b/tests/host/binding_polling_lease_test.cc new file mode 100644 index 00000000..b14ea0d8 --- /dev/null +++ b/tests/host/binding_polling_lease_test.cc @@ -0,0 +1,35 @@ +// #235 轮询任务代次租约:旧任务退出不能吞掉新会话的轮询请求。 + +#include + +#include "im_binding_polling_lease.h" +#include "support/test_support.h" + +using voicelife::runtime::BindingPollingLease; +using voicelife::test::Check; + +namespace { + +void TestFirstSessionCreatesAWorkerAndDuplicateDoesNot() { + BindingPollingLease lease; + Check(lease.Acquire(7), "没有轮询任务时,第一个 pending 会话必须创建任务"); + Check(!lease.Acquire(7), "同一会话重复结果不得创建第二个轮询任务"); + Check(lease.generation() == 7, "租约必须保留当前会话代次"); +} + +void TestOldWorkerCannotReleaseSessionAdoptedDuringExit() { + BindingPollingLease lease; + Check(lease.Acquire(7), "旧会话应先创建轮询任务"); + Check(!lease.Acquire(8), "旧任务存活时,新会话应由该任务接管而非并发创建"); + Check(lease.generation() == 8, "新会话必须接管轮询租约"); + Check(!lease.Release(7), "旧任务不得释放已经移交给新会话的租约"); + Check(lease.Release(8), "当前拥有者退出时必须释放租约"); +} + +} // namespace + +int main() { + TestFirstSessionCreatesAWorkerAndDuplicateDoesNot(); + TestOldWorkerCannotReleaseSessionAdoptedDuringExit(); + return 0; +} diff --git a/tests/host/binding_presentation_test.cc b/tests/host/binding_presentation_test.cc new file mode 100644 index 00000000..85c4f271 --- /dev/null +++ b/tests/host/binding_presentation_test.cc @@ -0,0 +1,133 @@ +// #235 绑定呈现:独立 OLED/TTS 文案、终态提示与脱敏边界。 + +#include + +#include "im_binding_presentation.h" +#include "support/test_support.h" + +using voicelife::im::BindingResult; +using voicelife::im::BindingState; +using voicelife::runtime::BindingPresentation; +using voicelife::runtime::IsCurrentBindingResult; +using voicelife::runtime::kBindingSystemSpeechCapacity; +using voicelife::runtime::kBindingTerminalDisplayDurationMs; +using voicelife::runtime::PresentBindingResult; +using voicelife::runtime::ShouldEndVoiceTurnAfterBindingResult; +using voicelife::test::Check; + +namespace { + +BindingResult Result(BindingState state, std::string code = {}, int expiry_minutes = 0) { + return {.state = state, + .display_code = std::move(code), + .expires_at = "2026-08-03T00:10:00.000Z", + .expires_in_minutes = expiry_minutes, + .message = {}}; +} + +void TestPendingShowsAndSpeaksTheSameCodeOnce() { + const BindingPresentation presentation = PresentBindingResult(Result(BindingState::kPending, "123456", 10)); + Check(presentation.keep_visible && presentation.announce && presentation.status_text == "10分钟内有效" && + presentation.display_duration_ms == 0 && presentation.content_text == "绑定 123456" && + presentation.speech_text == "请在微信公众号发送:绑定 123456", + "pending 必须在 OLED 与 TTS 中使用同一六位码,并明确有效期"); +} + +void TestAlreadyActiveKeepsTheCodeWithoutRepeatingSpeech() { + const BindingPresentation presentation = PresentBindingResult(Result(BindingState::kAlreadyActive, "123456", 5)); + Check(presentation.keep_visible && !presentation.announce && presentation.status_text == "5分钟内有效" && + presentation.display_duration_ms == 0 && presentation.content_text == "绑定 123456" && + presentation.speech_text.empty(), + "重复命令应恢复当前绑定码显示,但不得重复播报"); +} + +void TestTerminalStatesPromptTheUser() { + const BindingPresentation confirmed = PresentBindingResult(Result(BindingState::kConfirmed)); + Check(!confirmed.keep_visible && confirmed.announce && + confirmed.display_duration_ms == kBindingTerminalDisplayDurationMs && confirmed.resume_listening && + confirmed.content_text == "绑定成功" && confirmed.speech_text == "微信公众号绑定成功", + "confirmed 必须显示并播报成功,随后进入聆听"); + + const BindingPresentation expired = PresentBindingResult(Result(BindingState::kExpired)); + Check(!expired.keep_visible && expired.announce && + expired.display_duration_ms == kBindingTerminalDisplayDurationMs && !expired.resume_listening && + expired.content_text == "绑定已过期" && expired.speech_text == "绑定已过期,请重新获取绑定码", + "expired 必须提示用户重新获取绑定码"); + + const BindingPresentation cancelled = PresentBindingResult(Result(BindingState::kCancelled)); + Check(!cancelled.keep_visible && cancelled.announce && + cancelled.display_duration_ms == kBindingTerminalDisplayDurationMs && !cancelled.resume_listening && + cancelled.content_text == "绑定已取消" && cancelled.speech_text == "绑定已取消,请重新获取绑定码", + "cancelled 不得伪装成自然过期"); + + const BindingPresentation timed_out = PresentBindingResult(Result(BindingState::kTimedOut)); + Check(!timed_out.keep_visible && timed_out.announce && + timed_out.display_duration_ms == kBindingTerminalDisplayDurationMs && !timed_out.resume_listening && + timed_out.content_text == "等待超时" && timed_out.speech_text == "等待确认超时,请重新获取绑定码", + "timed_out 必须明确是本地等待截止"); +} + +void TestFailureStatesGiveSafeDeviceFeedback() { + for (const BindingState state : {BindingState::kUnavailable, BindingState::kFailed, + BindingState::kCredentialRejected, BindingState::kNotFound}) { + const BindingPresentation presentation = PresentBindingResult(Result(state)); + Check(!presentation.keep_visible && presentation.announce && + presentation.display_duration_ms == kBindingTerminalDisplayDurationMs && + !presentation.resume_listening && !presentation.status_text.empty() && + !presentation.content_text.empty() && !presentation.speech_text.empty(), + "创建或轮询失败必须有脱敏的 OLED/TTS 反馈,不能只留在 MCP 返回中"); + } +} + +void TestPollingStatesDoNotLeakOrSpamTheDisplay() { + for (const BindingState state : {BindingState::kWaiting, BindingState::kRetrying, BindingState::kIdle}) { + const BindingPresentation presentation = PresentBindingResult(Result(state)); + Check(!presentation.keep_visible && !presentation.announce && presentation.status_text.empty() && + presentation.content_text.empty() && presentation.speech_text.empty(), + "轮询中间态不得刷新屏幕、重复播报或透传内部详情"); + } +} + +void TestStaleRuntimeResultsAreRejectedBeforePresentation() { + const BindingResult old_result = Result(BindingState::kConfirmed); + Check(!IsCurrentBindingResult(old_result, old_result.generation + 1), + "重绑后的 Runtime 不得呈现旧会话的 confirmed 结果"); + Check(IsCurrentBindingResult(old_result, old_result.generation), "同代次结果必须可被呈现"); +} + +void TestBindingCodeEndsOnlyItsActiveVoiceTurn() { + Check(ShouldEndVoiceTurnAfterBindingResult(Result(BindingState::kPending, "123456", 10), true), + "活跃语音回合生成绑定码后,播报结束必须直接回待机"); + Check(ShouldEndVoiceTurnAfterBindingResult(Result(BindingState::kAlreadyActive, "123456", 10), true), + "活跃语音回合恢复现有绑定码后也不得进入 follow-up 聆听"); + Check(!ShouldEndVoiceTurnAfterBindingResult(Result(BindingState::kPending, "123456", 10), false), + "待机期间的绑定结果不得伪造语音回合收尾"); + Check(!ShouldEndVoiceTurnAfterBindingResult(Result(BindingState::kConfirmed), true), + "后台确认结果不得错误终止用户正在进行的其他语音回合"); +} + +void TestDeviceBindingSpeechNeverRequiresTruncation() { + for (const BindingState state : + {BindingState::kPending, BindingState::kConfirmed, BindingState::kExpired, BindingState::kCancelled, + BindingState::kTimedOut, BindingState::kUnavailable, BindingState::kCredentialRejected, + BindingState::kNotFound, BindingState::kFailed}) { + const BindingPresentation presentation = + PresentBindingResult(Result(state, state == BindingState::kPending ? "123456" : std::string{}, 10)); + Check(presentation.speech_text.size() < kBindingSystemSpeechCapacity, + "所有固定绑定 TTS 文案必须完整装入 BoardRequest,禁止静默截断"); + } +} + +} // namespace + +int main() { + TestPendingShowsAndSpeaksTheSameCodeOnce(); + TestAlreadyActiveKeepsTheCodeWithoutRepeatingSpeech(); + TestTerminalStatesPromptTheUser(); + TestFailureStatesGiveSafeDeviceFeedback(); + TestPollingStatesDoNotLeakOrSpamTheDisplay(); + TestStaleRuntimeResultsAreRejectedBeforePresentation(); + TestBindingCodeEndsOnlyItsActiveVoiceTurn(); + TestDeviceBindingSpeechNeverRequiresTruncation(); + return 0; +} diff --git a/tests/host/binding_use_case_test.cc b/tests/host/binding_use_case_test.cc index d9c3b4f7..2338df85 100644 --- a/tests/host/binding_use_case_test.cc +++ b/tests/host/binding_use_case_test.cc @@ -207,6 +207,45 @@ void TestRebindClearsSessionAndTerminalAllowsRestart() { Check(use_case.Start().state == BindingState::kPending, "终态后应允许显式开始下一次绑定"); } +void TestRebindInvalidatesResultsFromThePreviousRuntime() { + FakePairingPort first; + FakePairingPort second; + FakeClock clock; + Prepare(first); + Prepare(second); + first.queried = {Query("confirmed")}; + BindingUseCase use_case(first, clock); + use_case.set_user_id("user-fixture"); + + const auto started = use_case.Start(); + Check(started.generation != 0 && started.expires_in_minutes == 10, + "创建会话必须生成可用于丢弃旧结果的代次并保留有效期"); + clock.Advance(3000); + const auto terminal = use_case.Poll(); + use_case.Bind(second, clock, "user-fixture"); + + Check(terminal.state == BindingState::kConfirmed && terminal.generation != use_case.generation(), + "Runtime 重绑后,旧会话的终态结果必须能由其旧代次识别并丢弃"); + const auto restarted = use_case.Start(5); + Check(restarted.state == BindingState::kPending && restarted.generation == use_case.generation() && + restarted.expires_in_minutes == 5, + "重启策略必须清理本地会话并要求下一次显式开始,新的会话使用新代次"); +} + +void TestAbortingThePendingSessionAllowsARecoveryStart() { + FakePairingPort port; + FakeClock clock; + Prepare(port); + BindingUseCase use_case(port, clock); + use_case.set_user_id("user-fixture"); + + const auto pending = use_case.Start(); + const auto aborted = use_case.AbortPending(pending.generation); + Check(aborted.state == BindingState::kFailed && aborted.generation == pending.generation && !use_case.active(), + "轮询任务无法创建时必须终止本地 pending,不能留下无轮询的绑定码"); + Check(use_case.Start().state == BindingState::kPending, "终止后用户的下一次明确命令必须可以重新开始绑定"); +} + void TestRejectsOutOfRangeExpiry() { { FakePairingPort port; @@ -290,6 +329,8 @@ int main() { TestObservesWaitingNotFoundAndTimedOut(); TestPollAfterTerminalStaysIdle(); TestRebindClearsSessionAndTerminalAllowsRestart(); + TestRebindInvalidatesResultsFromThePreviousRuntime(); + TestAbortingThePendingSessionAllowsARecoveryStart(); TestRejectsOutOfRangeExpiry(); TestRejectsMalformedDisplayCode(); TestConcurrentBindAndStart(); diff --git a/tests/host/im_binding_mcp_tools_test.cc b/tests/host/im_binding_mcp_tools_test.cc index caceaac3..8c674bc8 100644 --- a/tests/host/im_binding_mcp_tools_test.cc +++ b/tests/host/im_binding_mcp_tools_test.cc @@ -109,7 +109,7 @@ void TestRejectsOutOfRangeExpiryAtBoundary() { } } -void TestInvokesStartHookOnceAndCarriesFields() { +void TestInvokesResultHookAndCarriesFields() { FakePairingPort port; FakeClock clock; Prepare(port); @@ -117,28 +117,48 @@ void TestInvokesStartHookOnceAndCarriesFields() { use_case.set_user_id("user-fixture"); McpServer server; int hook_count = 0; - Check(voicelife::runtime::RegisterImBindingMcpTools(server, use_case, [&hook_count] { ++hook_count; }).ok(), + voicelife::im::BindingResult hook_result; + Check(voicelife::runtime::RegisterImBindingMcpTools( + server, use_case, + [&hook_count, &hook_result](const voicelife::im::BindingResult& result) { + ++hook_count; + hook_result = result; + }) + .ok(), "带 hook 的绑定工具应可注册"); const auto first = server.call({.request_id = "bind-hook-1", .name = "im.binding.start", .arguments = {}}); - Check(first.status.ok() && first.output.at("status") == "pending" && hook_count == 1, - "创建成功必须恰好触发一次会话开始 hook"); + Check(first.status.ok() && first.output.at("status") == "pending" && hook_count == 1 && + hook_result.state == voicelife::im::BindingState::kPending && hook_result.display_code == "123456" && + hook_result.generation != 0, + "创建成功必须恰好触发一次并携带脱敏结果与代次的会话开始 hook"); const auto second = server.call({.request_id = "bind-hook-2", .name = "im.binding.start", .arguments = {}}); Check(second.status.ok() && second.output.at("status") == "already_active" && second.output.at("display_code") == "123456" && second.output.at("reason") == "session_active" && - second.output.at("retryable") == "false" && hook_count == 1, - "already_active 必须携带当前码且不再触发 hook"); + second.output.at("retryable") == "false" && hook_count == 2 && + hook_result.state == voicelife::im::BindingState::kAlreadyActive, + "already_active 必须投递当前码,以恢复被普通语音覆盖的 OLED 内容,但不重启轮询"); } void TestReturnsSpeakableUnavailableResult() { BindingUseCase use_case; McpServer server; - Check(voicelife::runtime::RegisterImBindingMcpTools(server, use_case).ok(), "绑定工具应可注册"); + int hook_count = 0; + voicelife::im::BindingResult hook_result; + Check(voicelife::runtime::RegisterImBindingMcpTools( + server, use_case, + [&hook_count, &hook_result](const voicelife::im::BindingResult& result) { + ++hook_count; + hook_result = result; + }) + .ok(), + "绑定工具应可注册"); const auto result = server.call({.request_id = "bind-6", .name = "im.binding.start", .arguments = {}}); Check(result.status.ok() && result.output.at("status") == "unavailable" && !result.output.at("message").empty() && result.output.at("reason") == "not_ready" && result.output.at("retryable") == "true" && - !result.output.contains("display_code"), - "IM 未 ready 时应返回可播报 unavailable 与稳定字段,而非 JSON-RPC error"); + !result.output.contains("display_code") && hook_count == 1 && + hook_result.state == voicelife::im::BindingState::kUnavailable, + "IM 未 ready 时必须投递可呈现 unavailable,而非只返回 MCP 文本"); } } // namespace @@ -147,7 +167,7 @@ int main() { TestRegistersAndCreatesBinding(); TestAcceptsExplicitExpiryAndRejectsInvalidArguments(); TestRejectsOutOfRangeExpiryAtBoundary(); - TestInvokesStartHookOnceAndCarriesFields(); + TestInvokesResultHookAndCarriesFields(); TestReturnsSpeakableUnavailableResult(); return 0; } diff --git a/tests/host/linx_esp_transport_contract_test.cc b/tests/host/linx_esp_transport_contract_test.cc index 9a21e2e5..8de70101 100644 --- a/tests/host/linx_esp_transport_contract_test.cc +++ b/tests/host/linx_esp_transport_contract_test.cc @@ -3,6 +3,7 @@ #include #include "support/test_support.h" +#include "voicelife/linx_esp/esp_websocket_transport.h" #include "voicelife/linx_esp/websocket_fragment_assembler.h" using voicelife::ErrorCode; @@ -28,6 +29,9 @@ WebSocketFragment Chunk(uint64_t generation, WebSocketOpcode opcode, std::string } // namespace int main() { + Check(voicelife::linx_esp::EspWebSocketTransportOptions{}.max_message_bytes == 64 * 1024, + "Linx WebSocket 默认消息上限必须为 64 KiB"); + WebSocketFragmentAssembler assembler(8); Check(IsWebSocketDataOpcode(WebSocketOpcode::kText) && IsWebSocketDataOpcode(WebSocketOpcode::kBinary) && diff --git a/tests/host/linx_mcp_bridge_test.cc b/tests/host/linx_mcp_bridge_test.cc index 1859962a..b6e3401c 100644 --- a/tests/host/linx_mcp_bridge_test.cc +++ b/tests/host/linx_mcp_bridge_test.cc @@ -73,6 +73,14 @@ int main() { Check(successful_outcome.success && successful_outcome.summary == "日程已创建", "成功 MCP 机器结果不得进入用户可见会话/屏幕语义"); + const auto binding_response = voicelife::Result::Success( + R"({"type":"mcp","payload":{"jsonrpc":"2.0","id":6,"result":{"content":[],"isError":false}}})"); + const auto binding_outcome = voicelife::runtime::InspectLinxMcpToolOutcome( + R"({"jsonrpc":"2.0","method":"tools/call","params":{"name":"im.binding.start","arguments":{}},"id":6})", + binding_response); + Check(binding_outcome.success && voicelife::runtime::IsBindingMcpToolSummary(binding_outcome.summary), + "绑定工具结果必须带独立语义,禁止降级成日程操作结果覆盖绑定码页面"); + const auto initialized_notification = voicelife::runtime::HandleLinxMcpPayload( R"({"jsonrpc":"2.0","method":"notifications/initialized","params":{}})", server, "remote-session"); Check(initialized_notification.ok() && initialized_notification.value.has_value() && diff --git a/tests/host/mcp_worker_policy_test.cc b/tests/host/mcp_worker_policy_test.cc new file mode 100644 index 00000000..eff2e57d --- /dev/null +++ b/tests/host/mcp_worker_policy_test.cc @@ -0,0 +1,14 @@ +#include "mcp_worker_policy.h" + +#include "support/test_support.h" + +int main() { + using voicelife::im::kImHttpRequestTimeoutMs; + using voicelife::runtime::kMcpResponseGraceMs; + using voicelife::runtime::kMcpResponseTimeoutMs; + using voicelife::test::Check; + + Check(kMcpResponseTimeoutMs > kImHttpRequestTimeoutMs, "MCP 不应在仍可能成功的 IM HTTP 请求之前超时"); + Check(kMcpResponseGraceMs >= 2000U, "MCP 应为网络请求完成后的调度与结果传递预留时间"); + return 0; +} diff --git a/tests/host/platform_assembly_test.cc b/tests/host/platform_assembly_test.cc index 6e3b924c..6c47d9e8 100644 --- a/tests/host/platform_assembly_test.cc +++ b/tests/host/platform_assembly_test.cc @@ -2,9 +2,26 @@ #include "platform_assemblies.h" #include "support/test_support.h" +#include "voicelife/display_esp/ssd1306_presentation_adapter.h" using voicelife::test::Check; +namespace { + +int g_display_initializations = 0; + +voicelife::Status InitializeDisplaySuccessfully() { + ++g_display_initializations; + return voicelife::Status::Ok(); +} + +voicelife::Status InitializeDisplayFailure() { + ++g_display_initializations; + return voicelife::Status::Error(voicelife::ErrorCode::kUnavailable, "display unavailable"); +} + +} // namespace + int main() { using voicelife::runtime::PlatformAssembly; using voicelife::runtime::SparkBotAssembly; @@ -36,7 +53,15 @@ int main() { snapshot.status_text = "测试"; Check(sparkbot_as_interface.presentation().Render(snapshot).ok(), "SparkBot Render 必须接受快照并入队"); - // Start() 生命周期:VoiceLife PCB 默认空实现成功;SparkBot 的 + // SSD1306 初始化是受控边界:必须实际调用面板初始化,并保留失败状态。 + using voicelife::display_esp::Ssd1306PresentationAdapter; + Ssd1306PresentationAdapter initialized_display(&InitializeDisplaySuccessfully); + Check(initialized_display.Start().ok() && g_display_initializations == 1, "SSD1306 Start 必须调用面板初始化"); + Ssd1306PresentationAdapter unavailable_display(&InitializeDisplayFailure); + Check(unavailable_display.Start().code == voicelife::ErrorCode::kUnavailable && g_display_initializations == 2, + "SSD1306 Start 必须传播面板初始化失败"); + + // Start() 生命周期:VoiceLife PCB 初始化 SSD1306;SparkBot 的 // ST7789/LVGL 初始化与显示任务仅 ESP 构建启用,host 下返回 // kUnavailable(不触碰硬件,不伪装成功)。 Check(pcb_as_interface.Start().ok(), "VoiceLife PCB Assembly Start 必须成功(默认空实现)"); diff --git a/tests/host/voice_interaction_controller_test.cc b/tests/host/voice_interaction_controller_test.cc index 10a1e0c5..6e89f95e 100644 --- a/tests/host/voice_interaction_controller_test.cc +++ b/tests/host/voice_interaction_controller_test.cc @@ -42,6 +42,17 @@ int main() { CheckTransition(controller, VoiceInteractionEvent::kInterruptCompleted, VoiceInteractionState::kStandby, VoiceInteractionAction::kRestoreStandby, "打断完成后应恢复本地待机"); + VoiceInteractionController terminal_controller; + CheckTransition(terminal_controller, VoiceInteractionEvent::kBootCompleted, VoiceInteractionState::kStandby, + VoiceInteractionAction::kRestoreStandby, "终结型回复测试应先进入待机"); + CheckTransition(terminal_controller, VoiceInteractionEvent::kWakeDetected, VoiceInteractionState::kListening, + VoiceInteractionAction::kStartVoiceTurn, "终结型回复应从有效语音回合开始"); + CheckTransition(terminal_controller, VoiceInteractionEvent::kTtsStarted, VoiceInteractionState::kSpeaking, + VoiceInteractionAction::kNone, "终结型回复应允许进入播报状态"); + CheckTransition(terminal_controller, VoiceInteractionEvent::kTerminalResponseCompleted, + VoiceInteractionState::kStandby, VoiceInteractionAction::kRestoreStandby, + "绑定码等终结型回复播报后应直接回待机,不进入 follow-up 聆听"); + CheckTransition(controller, VoiceInteractionEvent::kWakeDetected, VoiceInteractionState::kListening, VoiceInteractionAction::kStartVoiceTurn, "新一轮唤醒应可开始"); CheckTransition(controller, VoiceInteractionEvent::kTtsStarted, VoiceInteractionState::kSpeaking,