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/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_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..02997b5a --- /dev/null +++ b/components/voicelife_runtime/src/im_binding_presentation.cc @@ -0,0 +1,88 @@ +#include "im_binding_presentation.h" + +#include + +namespace voicelife::runtime { +namespace { + +std::string ExpiryText(int minutes) { return minutes > 0 ? std::to_string(minutes) + "分钟内有效" : "请尽快完成"; } + +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 {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "绑定成功", + .speech_text = "微信公众号绑定成功"}; + case im::BindingState::kExpired: + return {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "绑定已过期", + .speech_text = "绑定已过期,请重新获取绑定码"}; + case im::BindingState::kCancelled: + return {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "绑定已取消", + .speech_text = "绑定已取消,请重新获取绑定码"}; + case im::BindingState::kTimedOut: + return {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "等待超时", + .speech_text = "等待确认超时,请重新获取绑定码"}; + case im::BindingState::kUnavailable: + return {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "暂不可用", + .speech_text = "绑定功能暂不可用,请稍后再试"}; + case im::BindingState::kCredentialRejected: + return {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "设备凭据无效", + .speech_text = "设备凭据无效,无法完成绑定"}; + case im::BindingState::kNotFound: + return {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "会话不存在", + .speech_text = "绑定会话不存在,请重新获取绑定码"}; + case im::BindingState::kFailed: + return {.keep_visible = false, + .announce = true, + .status_text = "公众号绑定", + .content_text = "绑定失败", + .speech_text = "绑定失败,请稍后再试"}; + default: + return {}; + } +} + +bool IsCurrentBindingResult(const im::BindingResult& result, uint64_t current_generation) { + return result.generation == current_generation; +} + +} // 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..5359c737 --- /dev/null +++ b/components/voicelife_runtime/src/im_binding_presentation.h @@ -0,0 +1,34 @@ +#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; + +/** 绑定状态映射出的纯用户呈现语义,不包含任何显示或语音硬件句柄。 */ +struct BindingPresentation { + /** true 时绑定码界面应在普通语音回合结束后恢复。 */ + bool keep_visible = false; + /** true 时仅请求一次系统播报。 */ + bool announce = 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); + +} // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc index b4f3b419..96e00467 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -40,6 +40,8 @@ #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" @@ -164,8 +166,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"); @@ -389,39 +395,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 +555,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 +600,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 +678,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)。 @@ -1127,10 +1147,20 @@ 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; + } ++snapshot_.revision; // 真实状态迁移优先于临时 overlay,过期信号不能恢复旧回合的 UI。 overlay_active_ = false; CommitSnapshot(); + QueueDeferredBindingSpeechIfStandby(); switch (transition.value->action) { case voice::VoiceInteractionAction::kNone: return Status::Ok(); @@ -1356,7 +1386,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 +1425,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 +1502,95 @@ 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 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(); + } + + 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; + + 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 { + // 终态可能在用户正常对话期间抵达。保留提示,待当前回合回待机后 + // 再播报,避免轮询任务打断唤醒、按键和普通语音。 + deferred_binding_speech_ = presentation.speech_text; + } + } + void EnqueueVoiceEvidence(const voice::VoiceEvidence& evidence) { InteractionEventItem item{}; item.voice_evidence = true; @@ -1571,6 +1702,32 @@ 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(); + // 重绑/重启策略不允许旧 origin 的绑定码或成功提示留在屏幕上。 + // 非空闲回合会由紧随其后的交互事件接管显示;空闲时立即收口。 + if (interaction_.state() == voice::VoiceInteractionState::kStandby) { + snapshot_.mood = voice::VoiceMood::kIdle; + snapshot_.status_text = "空闲"; + 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 diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index b3799966..459c768b 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -356,6 +356,14 @@ 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(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..7b9e19de --- /dev/null +++ b/tests/host/binding_presentation_test.cc @@ -0,0 +1,112 @@ +// #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::PresentBindingResult; +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.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.content_text == "绑定 123456" && presentation.speech_text.empty(), + "重复命令应恢复当前绑定码显示,但不得重复播报"); +} + +void TestTerminalStatesPromptTheUser() { + const BindingPresentation confirmed = PresentBindingResult(Result(BindingState::kConfirmed)); + Check(!confirmed.keep_visible && confirmed.announce && confirmed.content_text == "绑定成功" && + confirmed.speech_text == "微信公众号绑定成功", + "confirmed 必须显示并播报成功"); + + const BindingPresentation expired = PresentBindingResult(Result(BindingState::kExpired)); + Check(!expired.keep_visible && expired.announce && expired.content_text == "绑定已过期" && + expired.speech_text == "绑定已过期,请重新获取绑定码", + "expired 必须提示用户重新获取绑定码"); + + const BindingPresentation cancelled = PresentBindingResult(Result(BindingState::kCancelled)); + Check(!cancelled.keep_visible && cancelled.announce && 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.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.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 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(); + 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; }