fix(otto): WebSocket direct clients not receiving MCP responses (#1992)

* Enhance Otto Robot camera support by adding configuration for OV3660. Updated config.h to define camera types and GPIO settings, modified config.json to include new camera options, and refactored otto_robot.cc for improved camera detection and initialization logic.

* fix: 移除 OttoEmojiDisplay 构造函数中的 SetTheme 调用以修复 LoadProhibited 崩溃

Made-with: Cursor

* refactor: improve audio service error handling and codec timeout management

- Updated AudioService to prevent input task termination on read timeout, introducing a delay instead.
- Enhanced NoAudioCodec to implement a read timeout for I2S channel reads.
- Adjusted WebSocketControlServer to set a control port for improved socket management.
- Added manufacturer information to the config.json for waveshare ESP32-Touch-LCD-3.5.

* fix(otto): WebSocket direct clients not receiving MCP responses

When a browser connects directly to the WebSocket control server (port
8080) and sends a JSON-RPC request, the MCP response was routed through
Application::SendMcpMessage -> protocol_->SendMcpMessage, which sends it
to the cloud protocol channel. As a result, the direct WebSocket client
never received the response, while the WeChat mini-program could because
it communicates via the cloud.

Fix:
- Add BroadcastMessage() to WebSocketControlServer, using
  httpd_queue_work + httpd_ws_send_frame_async to asynchronously
  send responses back to all connected clients on port 8080
- Add RegisterMcpBroadcastCallback() to Application, allowing an
  additional MCP send callback to be registered; SendMcpMessage()
  now invokes it alongside the cloud protocol
- Register the broadcast callback in OttoRobot after the WebSocket
  server starts successfully

Also add WebSocket direct-connect API documentation to README.md
with complete JSON-RPC 2.0 command examples.
This commit is contained in:
小鹏
2026-05-14 14:35:49 +08:00
committed by GitHub
parent ba27c12494
commit 67bf599149
6 changed files with 290 additions and 1 deletions

View File

@ -1063,12 +1063,19 @@ bool Application::CanEnterSleepMode() {
return true;
}
void Application::RegisterMcpBroadcastCallback(std::function<void(const std::string&)> 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);
}
});
}

View File

@ -10,6 +10,7 @@
#include <mutex>
#include <deque>
#include <memory>
#include <functional>
#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<void(const std::string&)> 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> ota_;
std::function<void(const std::string&)> mcp_broadcast_callback_;
bool has_server_time_ = false;
bool aborted_ = false;
bool assets_version_checked_ = false;

View File

@ -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 |

View File

@ -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 {

View File

@ -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<WsBroadcastJob*>(arg);
httpd_ws_frame_t ws_pkt = {};
ws_pkt.type = HTTPD_WS_TYPE_TEXT;
ws_pkt.payload = reinterpret_cast<uint8_t*>(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<WsBroadcastJob*>(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<char*>(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);
}
}
}

View File

@ -17,6 +17,8 @@ public:
size_t GetClientCount() const;
void BroadcastMessage(const std::string& message);
private:
httpd_handle_t server_handle_;
std::map<int, httpd_req_t*> clients_;