Skip to content
Draft
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
23 changes: 23 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,26 @@ if(CONFIG_VOICELIFE_BOARD_ESP_SPARKBOT)
)
add_custom_target(sparkbot_assets ALL DEPENDS "${CMAKE_BINARY_DIR}/sparkbot_assets.bin")
endif()

# SQLite 业务数据独立于 SparkBot 显示 assets/model 分区。镜像仅供首次受控
# 初始化或实板恢复使用;既不加入默认构建,也不加入 flash。输出文件不能命名为
# voicelife.bin,否则会覆盖同目录的应用固件。
if(CONFIG_VOICELIFE_STORAGE_FATFS)
partition_table_get_partition_info(VOICELIFE_STORAGE_SIZE "--partition-name voicelife" "size")
if(NOT VOICELIFE_STORAGE_SIZE)
message(FATAL_ERROR "已启用 FATFS storage,但分区表中不存在 voicelife 分区")
endif()
idf_build_get_property(VOICELIFE_IDF_PATH IDF_PATH)
idf_build_get_property(VOICELIFE_PYTHON PYTHON)
set(VOICELIFE_STORAGE_INITIAL_IMAGE "${CMAKE_BINARY_DIR}/voicelife_initial.bin")
add_custom_command(
OUTPUT "${VOICELIFE_STORAGE_INITIAL_IMAGE}"
COMMAND "${VOICELIFE_PYTHON}" "${VOICELIFE_IDF_PATH}/components/fatfs/wl_fatfsgen.py"
"${CMAKE_SOURCE_DIR}/components/voicelife_storage_fatfs/initial_volume"
--long_name_support --use_default_datetime --partition_size "${VOICELIFE_STORAGE_SIZE}"
--output_file "${VOICELIFE_STORAGE_INITIAL_IMAGE}" --sector_size 4096
DEPENDS "${CMAKE_SOURCE_DIR}/components/voicelife_storage_fatfs/initial_volume/.gitkeep"
COMMENT "生成首次初始化用 VoiceLife 日程数据卷镜像"
)
add_custom_target(voicelife_storage_image DEPENDS "${VOICELIFE_STORAGE_INITIAL_IMAGE}")
endif()
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@

namespace voicelife::linx {

/** @brief 处理 Linx MCP JSON-RPC payload 并返回完整响应消息。 */
using LinxMcpMessageHandler = std::function<Result<std::string>(std::string_view payload, std::string_view session_id)>;
/** @brief Linx 传输适配器接收 MCP JSON-RPC 响应的回调。 */
using LinxMcpResponseSink = std::function<void(Result<std::string>)>;

/**
* @brief 异步投递 MCP JSON-RPC payload。
*
* Provider 回调只能提交请求;持久化和工具调用在 MCP 模块的专属执行上下文完成。
*/
using LinxMcpMessageHandler =
std::function<Status(std::string_view payload, std::string_view session_id, LinxMcpResponseSink response_sink)>;

/** 将 Linx 协议和传输适配为稳定的语音 Provider 契约。 */
class LinxSpeechProviderAdapter final : public voice::SpeechProviderAdapter {
Expand Down
2 changes: 1 addition & 1 deletion components/voicelife_linx/src/linx_json_codec.cc
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Result<std::string> LinxJsonCodec::EncodeHello(const voice::VoiceSessionConfig&
cJSON_AddStringToObject(root.get(), "type", "hello");
cJSON_AddNumberToObject(root.get(), "version", 1);
cJSON* features = cJSON_AddObjectToObject(root.get(), "features");
cJSON_AddBoolToObject(features, "mcp", true);
cJSON_AddBoolToObject(features, "mcp", config.enable_mcp);
cJSON_AddStringToObject(root.get(), "transport", "websocket");

cJSON* audio = cJSON_AddObjectToObject(root.get(), "audio_params");
Expand Down
43 changes: 35 additions & 8 deletions components/voicelife_linx/src/linx_speech_provider.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@
namespace voicelife::linx {
namespace {

std::string EscapeMcpSessionId(std::string_view value) {
std::string escaped;
escaped.reserve(value.size());
for (const char character : value) {
if (character == '"' || character == '\\') escaped.push_back('\\');
escaped.push_back(character);
}
return escaped;
}

std::string WrapMcpResponse(std::string_view payload, std::string_view session_id) {
std::string envelope = "{\"type\":\"mcp\"";
if (!session_id.empty()) envelope += ",\"session_id\":\"" + EscapeMcpSessionId(session_id) + "\"";
return envelope + ",\"payload\":" + std::string(payload) + "}";
}

voice::VoiceEvent Event(voice::VoiceEventKind kind, std::string_view text = {}, bool aborted = false) {
voice::VoiceEvent event;
event.kind = kind;
Expand Down Expand Up @@ -65,6 +81,9 @@ Status LinxSpeechProviderAdapter::Connect(const voice::VoiceSessionConfig& confi
transport_connected_.store(false);
connected_.store(false);
generation_.store(config.generation);
// The transport drops queued PCM from other generations. Synchronize it
// before opening the connection so the first capture turn is accepted.
transport_.SetGeneration(config.generation);
output_sequence_.store(0);
{
std::lock_guard<std::mutex> lock(hello_mutex_);
Expand Down Expand Up @@ -391,19 +410,27 @@ void LinxSpeechProviderAdapter::OnText(std::string_view message) {
}
return;
case LinxMessageKind::kMcp: {
if (!config_.enable_mcp) {
// Runtime 没有装配 MCP 时不宣告该能力。服务端若仍下发 MCP,
// 忽略该请求,不能把语音会话误转为故障状态。
return;
}
if (!mcp_handler_) {
Emit(Event(voice::VoiceEventKind::kError, "Linx 收到 MCP 请求,但设备未配置 MCP handler"));
return;
}
const std::string session_id = inbound.session_id.value_or(ActiveSessionConfig().session_id);
if (const auto response = mcp_handler_(inbound.text, session_id);
response.ok() && response.value.has_value()) {
if (response.value->empty()) return;
const Status status = transport_.SendText(*response.value);
if (!status.ok()) Emit(Event(voice::VoiceEventKind::kError, status.message));
} else {
Emit(Event(voice::VoiceEventKind::kError, response.status.message));
}
const Status submitted =
mcp_handler_(inbound.text, session_id, [this, session_id](Result<std::string> response) {
if (!response.ok() || !response.value.has_value()) {
Emit(Event(voice::VoiceEventKind::kError, response.status.message));
return;
}
if (response.value->empty()) return;
const Status status = transport_.SendText(WrapMcpResponse(*response.value, session_id));
if (!status.ok()) Emit(Event(voice::VoiceEventKind::kError, status.message));
});
if (!submitted.ok()) Emit(Event(voice::VoiceEventKind::kError, submitted.message));
return;
}
case LinxMessageKind::kGoodbye:
Expand Down
54 changes: 47 additions & 7 deletions components/voicelife_linx_esp/src/esp_websocket_events.cc
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#include <algorithm>
#include <cstring>
#include <string>
#include <string_view>
#include <utility>

#include "esp_log.h"
#include "esp_timer.h"
#include "esp_tls_errors.h"
#include "esp_websocket_client.h"
#include "esp_websocket_impl.h"
Expand Down Expand Up @@ -140,17 +142,31 @@ void EspWebSocketTransport::Impl::TxLoop() {
delete item;
continue;
}
const int sent = item->kind == detail::LinxTxItem::Kind::kText
? esp_websocket_client_send_text(
client_, reinterpret_cast<const char*>(item->payload.data()),
static_cast<int>(item->payload.size()), pdMS_TO_TICKS(options_.network_timeout_ms))
: esp_websocket_client_send_bin(
client_, reinterpret_cast<const char*>(item->payload.data()),
static_cast<int>(item->payload.size()), pdMS_TO_TICKS(options_.network_timeout_ms));
if (item->kind == detail::LinxTxItem::Kind::kAudio && item->generation != generation_.load()) {
tx_audio_stale_dropped_frames_.fetch_add(1);
delete item;
LogTxAudioStatsIfDue();
continue;
}
// 20 ms 音频帧不能共享控制消息的 10 秒网络超时:一次慢写就会
// 占满有界队列并使整轮 ASR 失去上行。控制帧保留连接配置的预算,
// 音频单帧最多占用 200 ms,失败后交给既有重连收敛路径处理。
constexpr uint32_t kAudioSendBudgetMs = 200;
const uint32_t timeout_ms = item->kind == detail::LinxTxItem::Kind::kAudio
? std::min(options_.network_timeout_ms, kAudioSendBudgetMs)
: options_.network_timeout_ms;
const int sent =
item->kind == detail::LinxTxItem::Kind::kText
? esp_websocket_client_send_text(client_, reinterpret_cast<const char*>(item->payload.data()),
static_cast<int>(item->payload.size()), pdMS_TO_TICKS(timeout_ms))
: esp_websocket_client_send_bin(client_, reinterpret_cast<const char*>(item->payload.data()),
static_cast<int>(item->payload.size()), pdMS_TO_TICKS(timeout_ms));
const size_t want = item->payload.size();
const bool audio = item->kind == detail::LinxTxItem::Kind::kAudio;
delete item;
item = nullptr;
if (sent < 0 || static_cast<size_t>(sent) != want) {
if (audio) tx_audio_send_failed_frames_.fetch_add(1);
// 发送失败(写阻塞/短写/连接已断):不能直接 esp_websocket_client_stop
// ——stop 会停止客户端,ESP 内建自动重连(disable_auto_reconnect=false)
// 随之失效,Session 永久卡在非 Ready(无法二次唤醒/说话)。
Expand All @@ -170,9 +186,33 @@ void EspWebSocketTransport::Impl::TxLoop() {
}
continue;
}
if (audio) {
tx_audio_sent_frames_.fetch_add(1);
tx_audio_sent_bytes_.fetch_add(want);
}
LogTxAudioStatsIfDue();
}
}

void EspWebSocketTransport::Impl::LogTxAudioStatsIfDue() {
const int64_t now_us = esp_timer_get_time();
if (now_us - last_tx_audio_stats_us_ < 1000000) return;
last_tx_audio_stats_us_ = now_us;
const uint64_t queued = tx_audio_enqueued_frames_.load();
const uint64_t sent = tx_audio_sent_frames_.load();
const uint64_t dropped = tx_audio_queue_dropped_frames_.load();
const uint64_t stale = tx_audio_stale_dropped_frames_.load();
const uint64_t failed = tx_audio_send_failed_frames_.load();
if (queued == 0 && sent == 0 && dropped == 0 && stale == 0 && failed == 0) return;
ESP_LOGI(detail::kTag,
"LINX_TX_AUDIO_STATS queued=%llu queued_bytes=%llu sent=%llu sent_bytes=%llu queue_drop=%llu "
"stale_drop=%llu send_fail=%llu generation=%llu",
static_cast<unsigned long long>(queued), static_cast<unsigned long long>(tx_audio_enqueued_bytes_.load()),
static_cast<unsigned long long>(sent), static_cast<unsigned long long>(tx_audio_sent_bytes_.load()),
static_cast<unsigned long long>(dropped), static_cast<unsigned long long>(stale),
static_cast<unsigned long long>(failed), static_cast<unsigned long long>(generation_.load()));
}

void EspWebSocketTransport::Impl::HandleEnvelope(const detail::EventEnvelope& envelope) {
if (envelope.generation != generation_.load()) {
return;
Expand Down
16 changes: 16 additions & 0 deletions components/voicelife_linx_esp/src/esp_websocket_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "esp_crt_bundle.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_websocket_client.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
Expand Down Expand Up @@ -189,8 +190,23 @@ Status EspWebSocketTransport::Impl::SendAudio(const voice::AudioFrame& frame) {
item->payload = frame.payload;
if (xQueueSend(tx_queue_, &item, 0) != pdTRUE) {
delete item;
const uint64_t dropped = tx_audio_queue_dropped_frames_.fetch_add(1) + 1;
if (dropped == 1 || dropped % 25 == 0) {
ESP_LOGW(detail::kTag, "LINX_TX_AUDIO_QUEUE_FULL dropped=%llu queued=%llu sent=%llu",
static_cast<unsigned long long>(dropped),
static_cast<unsigned long long>(tx_audio_enqueued_frames_.load()),
static_cast<unsigned long long>(tx_audio_sent_frames_.load()));
}
return Status::Error(ErrorCode::kUnavailable, "ESP Linx TX 队列已满");
}
const uint64_t enqueued = tx_audio_enqueued_frames_.fetch_add(1) + 1;
tx_audio_enqueued_bytes_.fetch_add(frame.payload.size());
if (enqueued == 1 || enqueued % 50 == 0) {
ESP_LOGI(detail::kTag, "LINX_TX_AUDIO_ENQUEUED frames=%llu bytes=%llu generation=%llu",
static_cast<unsigned long long>(enqueued),
static_cast<unsigned long long>(tx_audio_enqueued_bytes_.load()),
static_cast<unsigned long long>(frame.generation));
}
return Status::Ok();
}

Expand Down
11 changes: 11 additions & 0 deletions components/voicelife_linx_esp/src/esp_websocket_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class EspWebSocketTransport::Impl final {
void WorkerLoop();
static void TxEntry(void* argument);
void TxLoop();
void LogTxAudioStatsIfDue();
void HandleQueueOverflow();
void HandleEnvelope(const detail::EventEnvelope& envelope);
void HandleData(const detail::EventEnvelope& envelope);
Expand All @@ -106,6 +107,16 @@ class EspWebSocketTransport::Impl final {
std::atomic<bool> queue_overflowed_{false};
std::atomic<bool> connect_waiting_{false};
std::atomic<uint64_t> generation_{0};
// 仅保存计数和代次,不记录音频内容或协议正文。它们用于把“未识别”区分为
// 采集、入队、实际网络发送或服务端识别阶段的问题。
std::atomic<uint64_t> tx_audio_enqueued_frames_{0};
std::atomic<uint64_t> tx_audio_enqueued_bytes_{0};
std::atomic<uint64_t> tx_audio_queue_dropped_frames_{0};
std::atomic<uint64_t> tx_audio_sent_frames_{0};
std::atomic<uint64_t> tx_audio_sent_bytes_{0};
std::atomic<uint64_t> tx_audio_stale_dropped_frames_{0};
std::atomic<uint64_t> tx_audio_send_failed_frames_{0};
int64_t last_tx_audio_stats_us_ = 0;
std::atomic<TransportState> state_{TransportState::kDisconnected};
std::recursive_mutex lifecycle_mutex_;
std::recursive_mutex close_mutex_;
Expand Down
7 changes: 4 additions & 3 deletions components/voicelife_mcp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
idf_component_register(
SRCS "src/mcp_server.cc" "src/mcp_json_writer.cc"
SRCS "src/mcp_server.cc" "src/mcp_json_writer.cc" "src/json_rpc_endpoint.cc" "src/mcp_request_executor.cc" "src/schedule_tools.cc"
"src/mcp_schedule_application.cc"
INCLUDE_DIRS "include"
REQUIRES voicelife_contracts
PRIV_REQUIRES yyjson
REQUIRES voicelife_contracts voicelife_schedule
PRIV_REQUIRES yyjson freertos
)
40 changes: 40 additions & 0 deletions components/voicelife_mcp/include/voicelife/mcp/json_rpc_endpoint.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#pragma once

#include <string>
#include <string_view>

#include "voicelife/contracts/status.h"

namespace voicelife::mcp {

/** @brief 提供 MCP 工具注册与调用的服务端。 */
class McpServer;

/**
* @brief 将受限 JSON-RPC MCP 请求映射到 McpServer。
*
* 该类不认识 WebSocket、Linx 信封、数据库或板级资源;传输 Adapter 负责
* 收发信封,业务 Adapter 负责向 McpServer 注册工具。
*/
class JsonRpcEndpoint final {
public:
/** @brief 以已注册工具的 MCP 服务端创建 endpoint。 @param server 工具服务端。 */
explicit JsonRpcEndpoint(const McpServer& server) : server_(server) {}

/** @brief 处理 initialize、tools/list、tools/call 和 notification。
* @param request JSON-RPC 请求。
* @return JSON-RPC 响应或解析错误。
*/
[[nodiscard]] Result<std::string> Handle(std::string_view request) const;
/** @brief 为未进入工具执行的受控拒绝生成 JSON-RPC 错误响应。
* @param request 原始 JSON-RPC 请求。
* @param message 受控拒绝说明。
* @return JSON-RPC 错误响应或解析错误。
*/
[[nodiscard]] static Result<std::string> UnavailableResponse(std::string_view request, std::string_view message);

private:
const McpServer& server_;
};

} // namespace voicelife::mcp
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#pragma once

#include <functional>
#include <string>
#include <string_view>

#include "voicelife/contracts/status.h"

namespace voicelife::mcp {

/** @brief MCP 请求完成后返回纯 JSON-RPC payload 的回调。 */
using McpJsonRpcResponseSink = std::function<void(Result<std::string>)>;

/** @brief MCP 请求的非实时执行函数。 */
using McpJsonRpcHandler = std::function<Result<std::string>(std::string_view)>;

/**
* @brief 在专属、有界工作上下文中执行 MCP JSON-RPC 请求。
*
* 传输回调只调用 Submit();工具路由、日程服务和持久化访问始终在该执行器的
* 工作任务中运行。该类不认识 Linx、语音会话、显示或具体存储实现。
*/
class McpRequestExecutor final {
public:
/** @brief 以请求处理函数创建有界执行器。 @param handler 请求处理函数。 */
explicit McpRequestExecutor(McpJsonRpcHandler handler);
/** @brief 停止执行器并释放其工作资源。 */
~McpRequestExecutor();

/** @brief 禁止复制执行器。 @param other 复制源执行器。 */
McpRequestExecutor(const McpRequestExecutor&) = delete;
/** @brief 禁止复制赋值执行器。 @param other 复制源执行器。 @return 本对象引用。 */
McpRequestExecutor& operator=(const McpRequestExecutor&) = delete;

/** @brief 创建专属 MCP 工作任务。 @return 启动结果。 */
[[nodiscard]] Status Start();
/** @brief 停止接收新请求,并释放工作任务。 */
void Stop();
/** @brief 将请求投递到有界队列;满时返回 kUnavailable。
* @param request JSON-RPC 请求。
* @param response_sink 异步响应回调。
* @return 投递结果。
*/
[[nodiscard]] Status Submit(std::string_view request, McpJsonRpcResponseSink response_sink);

private:
/** @brief 执行器队列和工作任务的私有实现。 */
class Impl;
Impl* impl_;
};

} // namespace voicelife::mcp
Loading