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:
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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_;
|
||||
|
||||
Reference in New Issue
Block a user