Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <string>

#include "voicelife/contracts/status.h"
#include "voicelife/display_esp/ssd1306_status_display.h"
#include "voicelife/voice/voice_ports.h"

namespace voicelife::display_esp {
Expand All @@ -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;

Expand All @@ -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 将显示快照映射为点阵屏文本界面并提交给旧渲染实现。
*
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::mutex> lock(state_mutex_);
last_snapshot_ = snapshot;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#include "voicelife/display_esp/ssd1306_status_display.h"

#include "font16_provider.h"
Expand Down Expand Up @@ -746,7 +746,7 @@
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 显示方向配置失败");
Expand Down
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
11 changes: 11 additions & 0 deletions components/voicelife_im/include/voicelife/im/im_http_policy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#pragma once

#include <cstdint>

namespace voicelife::im {

// 单次 IM 网关请求允许占用的最长时间。调用这类请求的上层等待预算必须更长,
// 否则网络操作可能已经成功,上层却先把结果报告为失败。
inline constexpr uint32_t kImHttpRequestTimeoutMs = 10U * 1000U;

} // namespace voicelife::im
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
15 changes: 13 additions & 2 deletions components/voicelife_im/src/transport/esp_http_transport.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,30 @@
#include <utility>

#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<int>(phase.size()), phase.data(),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)));
}

/// 把 esp_http_client 适配为 ImResponseReader,供 ReadResponseBody 判定读取完整性。
class EspResponseReader : public ImResponseReader {
public:
Expand Down Expand Up @@ -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<int>(kImHttpRequestTimeoutMs);
// GET 没有 body,但仍需容纳 URL、Bearer 头与 esp_http_client 生成的请求头。
// 保留固定下限,POST 则按受控请求体继续扩展。
config.buffer_size_tx = std::max(kMinimumTransmitBufferBytes, request.body.size() + 32);
Expand All @@ -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;
Expand Down Expand Up @@ -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<int>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
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
Loading
Loading