diff --git a/main/application.cc b/main/application.cc index 0a809fa..a30bb7a 100644 --- a/main/application.cc +++ b/main/application.cc @@ -1063,12 +1063,19 @@ bool Application::CanEnterSleepMode() { return true; } +void Application::RegisterMcpBroadcastCallback(std::function callback) { + mcp_broadcast_callback_ = std::move(callback); +} + void Application::SendMcpMessage(const std::string& payload) { // Always schedule to run in main task for thread safety - Schedule([this, payload = std::move(payload)]() { + Schedule([this, payload](){ if (protocol_) { protocol_->SendMcpMessage(payload); } + if (mcp_broadcast_callback_) { + mcp_broadcast_callback_(payload); + } }); } diff --git a/main/application.h b/main/application.h index 7ca7af4..cb635d4 100644 --- a/main/application.h +++ b/main/application.h @@ -10,6 +10,7 @@ #include #include #include +#include #include "protocol.h" #include "ota.h" @@ -108,6 +109,7 @@ public: bool UpgradeFirmware(const std::string& url, const std::string& version = ""); bool CanEnterSleepMode(); void SendMcpMessage(const std::string& payload); + void RegisterMcpBroadcastCallback(std::function callback); void SetAecMode(AecMode mode); AecMode GetAecMode() const { return aec_mode_; } void PlaySound(const std::string_view& sound); @@ -136,6 +138,8 @@ private: AudioService audio_service_; std::unique_ptr ota_; + std::function mcp_broadcast_callback_; + bool has_server_time_ = false; bool aborted_ = false; bool assets_version_checked_ = false; diff --git a/main/boards/otto-robot/README.md b/main/boards/otto-robot/README.md index 9aac113..ad795ba 100644 --- a/main/boards/otto-robot/README.md +++ b/main/boards/otto-robot/README.md @@ -205,3 +205,212 @@ otto 机器人具有丰富的动作能力,包括行走、转向、跳跃、摇 **说明**: 小智控制机器人动作是创建新的任务在后台控制,动作执行期间仍可接受新的语音指令。可以通过"停止"语音指令立即停下Otto。 +--- + +## WebSocket 直连调试接口 + +Otto 机器人内置 WebSocket 服务器,可在局域网内直接调试,无需经过云端。 + +**连接地址:** `ws://<设备IP>:8080/ws` + +> 协议格式:JSON-RPC 2.0,`id` 字段自行递增即可。 + +### 连接方式 + +1. 确认 Otto 已连上 WiFi,获取 IP 地址(可通过小程序或串口日志查看) +2. 打开任意 WebSocket 调试工具(如 [websocket.org/echo](https://websocket.org/echo) 或浏览器控制台) +3. 连接 `ws://192.168.x.x:8080/ws`(注意末尾必须有 `/ws`) +4. 发送 JSON 命令,响应会直接返回到同一连接 + +--- + +### 一、协议初始化(首次连接建议先发) + +```json +{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}},"id":1} +``` + +--- + +### 二、获取工具列表 + +```json +{"jsonrpc":"2.0","method":"tools/list","params":{},"id":2} +``` + +--- + +### 三、Otto 机器人工具命令 + +#### 获取舵机微调值 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.get_trims","arguments":{}},"id":3} +``` + +#### 设置单个舵机微调(永久保存) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.set_trim","arguments":{"servo_type":"left_leg","trim_value":5}},"id":4} +``` + +`servo_type` 可选值:`left_leg` / `right_leg` / `left_foot` / `right_foot` / `left_hand` / `right_hand`,`trim_value` 范围 `-50` ~ `50` + +#### 行走(前进3步) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"walk","steps":3,"speed":700,"direction":1}},"id":5} +``` + +#### 后退 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"walk","steps":3,"speed":700,"direction":-1}},"id":6} +``` + +#### 左转 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"turn","steps":3,"speed":700,"direction":-1}},"id":7} +``` + +#### 跳跃 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"jump","steps":1,"speed":500}},"id":8} +``` + +#### 摇摆 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"swing","steps":5,"speed":600,"amount":30}},"id":9} +``` + +#### 太空步 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"moonwalk","steps":3,"speed":800,"direction":1,"amount":30}},"id":10} +``` + +#### 坐下 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"sit"}},"id":11} +``` + +#### 复位 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"home"}},"id":12} +``` + +#### 展示动作 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"showcase"}},"id":13} +``` + +#### 举手(需手部舵机) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"hands_up","speed":500,"direction":1}},"id":14} +``` + +#### 挥手(需手部舵机) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.action","arguments":{"action":"hand_wave","direction":1}},"id":15} +``` + +#### 立即停止所有动作 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.stop","arguments":{}},"id":16} +``` + +#### 获取运动状态(返回 `"moving"` 或 `"idle"`) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.get_status","arguments":{}},"id":17} +``` + +#### 获取 IP 地址 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.get_ip","arguments":{}},"id":18} +``` + +#### 获取电池电量 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.battery.get_level","arguments":{}},"id":19} +``` + +--- + +### 四、系统通用工具 + +#### 获取设备状态(音量/网络/电池等) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.get_device_status","arguments":{}},"id":20} +``` + +#### 设置音量(0~100) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.audio_speaker.set_volume","arguments":{"volume":70}},"id":21} +``` + +#### 重启设备 + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.reboot","arguments":{}},"id":22} +``` + +--- + +### 五、自定义舵机序列 + +#### 普通移动模式(逐步移动各舵机) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.servo_sequences","arguments":{"sequence":"{\"a\":[{\"s\":{\"ll\":110,\"rl\":70},\"v\":800},{\"s\":{\"ll\":90,\"rl\":90},\"v\":800}],\"d\":0}"}},"id":23} +``` + +#### 振荡器模式(双臂摆动) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.servo_sequences","arguments":{"sequence":"{\"a\":[{\"osc\":{\"a\":{\"lh\":30,\"rh\":30},\"o\":{\"lh\":90,\"rh\":90},\"ph\":{\"rh\":180},\"p\":500,\"c\":5.0}}]}"}},"id":24} +``` + +#### 振荡器模式(左右摇摆波浪) + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.otto.servo_sequences","arguments":{"sequence":"{\"a\":[{\"osc\":{\"a\":{\"ll\":20,\"rl\":20},\"o\":{\"ll\":90,\"rl\":90},\"ph\":{\"rl\":180},\"p\":600,\"c\":5.0}}]}"}},"id":25} +``` + +**序列舵机键名说明:** + +| 键名 | 舵机 | 说明 | +|------|------|------| +| `ll` | 左腿 | 0=完全外展,90=中立,180=完全内收 | +| `rl` | 右腿 | 0=完全内收,90=中立,180=完全外展 | +| `lf` | 左脚 | 0=完全向上,90=水平,180=完全向下 | +| `rf` | 右脚 | 0=完全向下,90=水平,180=完全向上 | +| `lh` | 左手 | 0=完全向下,90=水平,180=完全向上 | +| `rh` | 右手 | 0=完全向上,90=水平,180=完全向下 | + +--- + +### 六、动作参数速查 + +| 参数 | 说明 | 范围 | 默认 | +|------|------|------|------| +| `steps` | 动作步数 | 1~100 | 3 | +| `speed` | 速度(毫秒,越小越快) | 100~3000 | 700 | +| `direction` | 方向(1=前/左,-1=后/右) | -1~1 | 1 | +| `amount` | 幅度 | 0~170 | 30 | +| `arm_swing` | 手臂摆动幅度 | 0~170 | 50 | +| `trim_value` | 舵机微调 | -50~50 | 0 | + diff --git a/main/boards/otto-robot/otto_robot.cc b/main/boards/otto-robot/otto_robot.cc index 754f609..ee5d139 100644 --- a/main/boards/otto-robot/otto_robot.cc +++ b/main/boards/otto-robot/otto_robot.cc @@ -230,7 +230,16 @@ private: if (!ws_control_server_->Start(8080)) { delete ws_control_server_; ws_control_server_ = nullptr; + return; } + // 将 MCP 响应同时广播回连接到 8080 端口的 WebSocket 客户端 + Application::GetInstance().RegisterMcpBroadcastCallback( + [this](const std::string& payload) { + if (ws_control_server_) { + ws_control_server_->BroadcastMessage(payload); + } + } + ); } void StartNetwork() override { diff --git a/main/boards/otto-robot/websocket_control_server.cc b/main/boards/otto-robot/websocket_control_server.cc index caa495c..b3b8244 100644 --- a/main/boards/otto-robot/websocket_control_server.cc +++ b/main/boards/otto-robot/websocket_control_server.cc @@ -190,3 +190,61 @@ void WebSocketControlServer::RemoveClient(httpd_req_t *req) { size_t WebSocketControlServer::GetClientCount() const { return clients_.size(); } + +struct WsBroadcastJob { + httpd_handle_t server; + int fd; + char* payload; + size_t len; +}; + +static void ws_broadcast_send_job(void* arg) { + WsBroadcastJob* job = static_cast(arg); + + httpd_ws_frame_t ws_pkt = {}; + ws_pkt.type = HTTPD_WS_TYPE_TEXT; + ws_pkt.payload = reinterpret_cast(job->payload); + ws_pkt.len = job->len; + ws_pkt.final = true; + + esp_err_t ret = httpd_ws_send_frame_async(job->server, job->fd, &ws_pkt); + if (ret != ESP_OK) { + ESP_LOGE("WSControl", "BroadcastMessage: send failed fd=%d err=%d", job->fd, ret); + } + + free(job->payload); + free(job); +} + +void WebSocketControlServer::BroadcastMessage(const std::string& message) { + if (!server_handle_ || clients_.empty()) { + return; + } + + for (auto& [fd, req] : clients_) { + WsBroadcastJob* job = static_cast(malloc(sizeof(WsBroadcastJob))); + if (!job) { + ESP_LOGE(TAG, "BroadcastMessage: failed to allocate job"); + continue; + } + + job->server = server_handle_; + job->fd = fd; + job->len = message.length(); + job->payload = static_cast(malloc(message.length() + 1)); + if (!job->payload) { + ESP_LOGE(TAG, "BroadcastMessage: failed to allocate payload"); + free(job); + continue; + } + memcpy(job->payload, message.c_str(), message.length()); + job->payload[message.length()] = '\0'; + + esp_err_t ret = httpd_queue_work(server_handle_, ws_broadcast_send_job, job); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "BroadcastMessage: httpd_queue_work failed fd=%d err=%d", fd, ret); + free(job->payload); + free(job); + } + } +} diff --git a/main/boards/otto-robot/websocket_control_server.h b/main/boards/otto-robot/websocket_control_server.h index f8e5e41..abc349f 100644 --- a/main/boards/otto-robot/websocket_control_server.h +++ b/main/boards/otto-robot/websocket_control_server.h @@ -17,6 +17,8 @@ public: size_t GetClientCount() const; + void BroadcastMessage(const std::string& message); + private: httpd_handle_t server_handle_; std::map clients_;