Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions components/voicelife_im/include/voicelife/im/im_binding_use_case.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -64,18 +69,29 @@ 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;
ImPairingClock* clock_ = nullptr;
std::optional<std::string> user_id_;
std::unique_ptr<PairingSessionController> controller_;
BindingState state_ = BindingState::kIdle;
int active_expiry_minutes_ = 0;
uint64_t generation_ = 0;
mutable std::mutex mutex_;
};

Expand Down
60 changes: 54 additions & 6 deletions components/voicelife_im/src/im_binding_use_case.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}

Expand All @@ -58,25 +60,38 @@ 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<std::string> user_id) {
std::lock_guard<std::mutex> lock(mutex_);
if (user_id_ != user_id) {
controller_.reset();
active_expiry_minutes_ = 0;
++generation_;
state_ = BindingState::kIdle;
}
user_id_ = std::move(user_id);
}

BindingResult BindingUseCase::Start(int expires_in_minutes) {
std::lock_guard<std::mutex> 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()) {
Expand All @@ -85,29 +100,57 @@ 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<PairingSessionController>(*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;
}

BindingResult BindingUseCase::Poll() {
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(mutex_);
return controller_ != nullptr && controller_->active();
Expand All @@ -118,4 +161,9 @@ BindingState BindingUseCase::state() const {
return state_;
}

uint64_t BindingUseCase::generation() const {
std::lock_guard<std::mutex> lock(mutex_);
return generation_;
}

} // namespace voicelife::im
2 changes: 1 addition & 1 deletion components/voicelife_runtime/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 5 additions & 4 deletions components/voicelife_runtime/src/im_binding_mcp_tools.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(properties.value<int64_t>("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);
Expand Down
9 changes: 4 additions & 5 deletions components/voicelife_runtime/src/im_binding_mcp_tools.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<void()>;
/// 每次 Start 的脱敏结果回调;Runtime 据此投递设备呈现语义,并仅对 pending 启动轮询。
using BindingResultHook = std::function<void(const im::BindingResult&)>;

/**
* @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
39 changes: 39 additions & 0 deletions components/voicelife_runtime/src/im_binding_polling_lease.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#pragma once

#include <atomic>
#include <cstdint>

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<uint64_t> generation_{0};
};

} // namespace voicelife::runtime
88 changes: 88 additions & 0 deletions components/voicelife_runtime/src/im_binding_presentation.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#include "im_binding_presentation.h"

#include <string>

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
34 changes: 34 additions & 0 deletions components/voicelife_runtime/src/im_binding_presentation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#pragma once

#include <cstddef>
#include <cstdint>
#include <string>

#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
Loading
Loading