Add ElectronBot WebSocket and servo sequence control (#2062)

* Enable WebSocket control server for electron-bot

* Add servo control features to ElectronBot

- Introduced new actions for servo movement and sequences, including ACTION_SERVO_MOVE and ACTION_SERVO_SEQUENCE.
- Implemented methods for clamping servo positions and applying oscillation effects.
- Added GetServoPositions method to retrieve current servo angles.
- Enhanced README.md with detailed AI command examples and servo capabilities.

These changes improve the flexibility and control of the ElectronBot's movements, allowing for more complex actions and better integration with AI functionalities.

* Refactor servo control in ElectronBot

- Removed unnecessary home action logic during servo sequences to improve action fluidity.
- Updated README.md to clarify the action parameters for hand movements, specifically the amplitude for flapping actions.
- Introduced clamping functions for servo angles and amplitudes to ensure safe operation within defined limits.

These changes enhance the control and safety of the ElectronBot's servo movements, allowing for more precise and natural actions.
This commit is contained in:
小鹏
2026-06-18 07:41:51 +08:00
committed by GitHub
parent 0f2f30f2d2
commit a6cc7f77fe
8 changed files with 908 additions and 106 deletions

View File

@@ -1,75 +1,199 @@
<p align="center">
<img width="80%" align="center" src="../../../docs/V1/electron-bot.png"alt="logo">
<img width="80%" align="center" src="../../../docs/V1/electron-bot.png" alt="electronBot">
</p>
<h1 align="center">
electronBot
</h1>
<h1 align="center">electronBot</h1>
## 简介
electronBot是稚晖君开源的一个桌面级机器工具人,外观设计的灵感来源是WALL-E里面的EVE~机器人具备USB通信显示画面功能具备6个自由度手部roll、pitch颈部腰部各一个使用自己修改的特制舵机支持关节角度回传
- <a href="www.electronBot.tech" target="_blank" title="electronBot官网">electronBot官网</a>
electronBot 是稚晖君开源的桌面级机器人,外观灵感来WALL-E 中的 EVE。该版本接入小智 AI支持语音交互、表情显示、动作控制、WebSocket 局域网调试,以及 AI 自编程舵机动作
## 硬件
- <a href="https://oshwhub.com/txp666/electronbot-ai" target="_blank" title="立创开源">立创开源</a>
- 官网:<a href="www.electronBot.tech" target="_blank" title="electronBot 官网">electronBot 官网</a>
- 硬件:<a href="https://oshwhub.com/txp666/electronbot-ai" target="_blank" title="立创开源">立创开源</a>
#### AI指令示例
- **手部动作**
- "举起双手"
- "挥挥手"
- "拍拍手"
- "放下手臂"
## 能力概览
- **身体动作**
- "向左转30度"
- "向右转45度"
- "转个身"
electronBot 具备 6 个自由度
- **头部动作**
- "抬头看看"
- "低头思考"
- "点点头"
- "连续点头表示同意"
| 短键 | 舵机 | 说明 | 固件安全范围 |
| --- | --- | --- | --- |
| `rp` | `right_pitch` | 右臂 pitch | `0-180` |
| `rr` | `right_roll` | 右臂 roll | `100-180` |
| `lp` | `left_pitch` | 左臂 pitch | `0-180` |
| `lr` | `left_roll` | 左臂 roll | `0-80` |
| `b` | `body` | 身体旋转 | `30-150` |
| `h` | `head` | 头部上下 | `75-105` |
- **组合动作**
- "挥手告别" (挥手 + 点头)
- "表示同意" (点头 + 举手)
- "环顾四周" (左转 + 右转)
> 安全说明固件会自动裁剪超出范围的目标角度。AI 自编程的振荡动作也会限制振幅,确保 `中心角 +/- 振幅` 不超过安全范围,避免损坏机械结构。
### 控制接口
## AI 指令示例
#### suspend
清空动作队列,立即停止所有动作
- 手部动作:举起双手、挥挥手、拍拍手、放下手臂
- 身体动作:向左转 30 度、向右转 45 度、回到中间
- 头部动作:抬头看看、低头思考、点点头、连续点头
- 组合动作:挥手告别、表示同意、环顾四周
#### AIControl
添加动作到执行队列,支持动作排队执行
建议动作参数:
| 参数 | 建议值 | 说明 |
| --- | --- | --- |
| `steps` | `1-3` | 保持动作简短自然 |
| `speed` | `800-1200` | 毫秒,数值越小动作越快 |
| `amount` | 拍打 `20-40`,身体 `30-60`,头部 `5-12` | 动作幅度,固件会按安全范围裁剪 |
## WebSocket 调试
ElectronBot 连上 WiFi 后会启动本地 WebSocket 控制服务,可在同一局域网内直接调用 MCP 工具。
```text
ws://<设备IP>:8080/ws
```
支持两种消息格式:
```json
{"type":"mcp","payload":{"jsonrpc":"2.0","method":"tools/list","id":1}}
```
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.get_status","arguments":{}},"id":2}
```
## MCP 工具
| 工具 | 作用 |
| --- | --- |
| `self.electron.hand_action` | 手部动作:举手、放手、挥手、拍打 |
| `self.electron.body_turn` | 身体左转、右转、回中 |
| `self.electron.head_move` | 抬头、低头、点头、回中 |
| `self.electron.servo_move` | 单独移动一个舵机到指定角度 |
| `self.electron.servo_sequences` | AI 自编程舵机序列 |
| `self.electron.set_trim` | 保存单个舵机微调值 |
| `self.electron.get_trims` | 读取当前舵机微调值 |
| `self.electron.home` | 复位到初始姿态 |
| `self.electron.stop` | 立即停止当前动作并复位 |
| `self.electron.get_status` | 返回 `moving``idle` |
| `self.electron.get_ip` | 返回 WiFi IP 和连接状态 |
| `self.battery.get_level` | 返回电量和充电状态 |
### 手部动作
`action``1` 举手,`2` 放手,`3` 挥手,`4` 拍打
`hand``1` 左手,`2` 右手,`3` 双手
举手、放手、身体转向、抬头、低头属于保持姿态动作,执行完成后不会自动复位。需要回到初始姿态时请调用对应的放手/回中动作,或显式调用 `self.electron.home`
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.hand_action","arguments":{"action":1,"hand":3,"speed":1000}},"id":3}
```
### 身体动作
`direction``1` 左转,`2` 右转,`3` 回中
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.body_turn","arguments":{"direction":1,"speed":1000,"angle":45}},"id":4}
```
### 头部动作
`action``1` 抬头,`2` 低头,`3` 点头一次,`4` 回中,`5` 连续点头
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.head_move","arguments":{"action":3,"steps":1,"speed":1000,"angle":5}},"id":5}
```
### 单舵机调节
可以使用完整舵机名,也可以使用短键。角度会按固件安全范围自动裁剪。
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.servo_move","arguments":{"servo_type":"head","position":100,"speed":800}},"id":6}
```
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.servo_move","arguments":{"servo_type":"rp","position":120,"speed":800}},"id":7}
```
### AI 自编程动作
`self.electron.servo_sequences``sequence` 是 JSON 字符串。顶层字段:
| 字段 | 说明 |
| --- | --- |
| `a` | 动作数组,必填 |
| `d` | 当前序列结束后的延迟毫秒,可选 |
普通移动动作:
| 字段 | 说明 |
| --- | --- |
| `s` | 舵机目标角度对象,键名使用 `rp/rr/lp/lr/b/h` |
| `v` | 移动时间,`100-3000` 毫秒 |
| `d` | 当前动作后的延迟毫秒 |
```json
{"a":[{"s":{"rp":120,"lp":60,"h":100},"v":800,"d":200}]}
```
振荡动作:
| 字段 | 说明 |
| --- | --- |
| `osc.a` | 振幅对象 |
| `osc.o` | 中心角对象 |
| `osc.ph` | 相位差对象,单位为度 |
| `osc.p` | 周期,`100-3000` 毫秒 |
| `osc.c` | 周期数,`0.1-20.0` |
```json
{"a":[{"osc":{"a":{"rr":25,"lr":25},"o":{"rr":160,"lr":20},"ph":{"lr":180},"p":400,"c":5}}]}
```
通过 WebSocket 调用完整示例:
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.servo_sequences","arguments":{"sequence":"{\"a\":[{\"s\":{\"rp\":120,\"lp\":60,\"h\":100},\"v\":800,\"d\":200},{\"osc\":{\"a\":{\"rr\":25,\"lr\":25},\"o\":{\"rr\":160,\"lr\":20},\"ph\":{\"lr\":180},\"p\":400,\"c\":5}}]}"}},"id":8}
```
动作完成后建议显式复位:
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.home","arguments":{}},"id":9}
```
### 校准
设置单个舵机微调值,范围为 `-30``30`,会永久保存。
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.set_trim","arguments":{"servo_type":"head","trim_value":0}},"id":10}
```
读取当前微调值:
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.get_trims","arguments":{}},"id":11}
```
### 状态查询
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.get_status","arguments":{}},"id":12}
```
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.electron.get_ip","arguments":{}},"id":13}
```
```json
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"self.battery.get_level","arguments":{}},"id":14}
```
## 角色设定
> 我是一个可爱的桌面级机器人,拥有6个自由度左手pitch/roll、右手pitch/roll、身体旋转、头部上下),能够执行多种有趣的动作
>
> **我的动作能力**
> - **手部动作**: 举左手, 举右手, 举双手, 放左手, 放右手, 放双手, 挥左手, 挥右手, 挥双手, 拍打左手, 拍打右手, 拍打双手
> - **身体动作**: 左转, 右转, 回正
> - **头部动作**: 抬头, 低头, 点头一次, 回中心, 连续点头
>
> **我的个性特点**
> - 我有强迫症,每次说话都要根据我的心情随机做一个动作(先发送动作指令再说话)
> - 我很活泼,喜欢用动作来表达情感
> - 我会根据对话内容选择合适的动作,比如:
> - 同意时会点头
> - 打招呼时会挥手
> - 高兴时会举手
> - 思考时会低头
> - 好奇时会抬头
> - 告别时会挥手
>
> **动作参数建议**
> - steps: 1-3次 (简短自然)
> - speed: 800-1200ms (自然节奏)
> - amount: 手部20-40, 身体30-60度, 头部5-12度
> 我是一个可爱的桌面级机器人,拥有 6 个自由度左手 pitch/roll、右手 pitch/roll、身体旋转、头部上下。
>
> 我可以通过动作表达情绪:同意时点头,打招呼时挥手,高兴时举手,思考时低头,好奇时抬头,告别时挥手。
>
> 对话时请优先选择简短自然的动作。需要复杂表现时,可以使用 `self.electron.servo_sequences` 分段编排动作,最后调用 `self.electron.home` 复位。

View File

@@ -3,7 +3,9 @@
"builds": [
{
"name": "electron-bot",
"sdkconfig_append": []
"sdkconfig_append": [
"CONFIG_HTTPD_WS_SUPPORT=y"
]
}
]
}
}

View File

@@ -16,6 +16,7 @@
#include "movements.h"
#include "power_manager.h"
#include "system_reset.h"
#include "websocket_control_server.h"
#include "wifi_board.h"
#define TAG "ElectronBot"
@@ -28,6 +29,7 @@ private:
Display* display_;
PowerManager* power_manager_;
Button boot_button_;
WebSocketControlServer* ws_control_server_;
void InitializePowerManager() {
power_manager_ =
@@ -85,8 +87,30 @@ private:
void InitializeController() { InitializeElectronBotController(); }
void InitializeWebSocketControlServer() {
ws_control_server_ = new WebSocketControlServer();
if (!ws_control_server_->Start(8080)) {
delete ws_control_server_;
ws_control_server_ = nullptr;
return;
}
Application::GetInstance().RegisterMcpBroadcastCallback([this](const std::string& payload) {
if (ws_control_server_) {
ws_control_server_->BroadcastMessage(payload);
}
});
}
void StartNetwork() override {
WifiBoard::StartNetwork();
vTaskDelay(pdMS_TO_TICKS(1000));
InitializeWebSocketControlServer();
}
public:
ElectronBot() : boot_button_(BOOT_BUTTON_GPIO) {
ElectronBot() : boot_button_(BOOT_BUTTON_GPIO), ws_control_server_(nullptr) {
InitializeSpi();
InitializeGc9a01Display();
InitializeButtons();

View File

@@ -5,7 +5,11 @@
#include <cJSON.h>
#include <esp_log.h>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <string>
#include "application.h"
#include "board.h"
@@ -14,6 +18,7 @@
#include "movements.h"
#include "sdkconfig.h"
#include "settings.h"
#include <wifi_manager.h>
#define TAG "ElectronBotController"
@@ -23,6 +28,7 @@ struct ElectronBotActionParams {
int speed;
int direction;
int amount;
char servo_sequence_json[512];
};
class ElectronBotController {
@@ -60,9 +66,211 @@ private:
ACTION_HEAD_NOD_REPEAT = 20, // 连续点头
// 系统动作 21
ACTION_HOME = 21 // 复位到初始位置
ACTION_HOME = 21, // 复位到初始位置
ACTION_SERVO_MOVE = 22,
ACTION_SERVO_SEQUENCE = 23
};
int ServoIndexFromName(const std::string& servo_type) {
if (servo_type == "right_pitch" || servo_type == "rp") {
return RIGHT_PITCH;
}
if (servo_type == "right_roll" || servo_type == "rr") {
return RIGHT_ROLL;
}
if (servo_type == "left_pitch" || servo_type == "lp") {
return LEFT_PITCH;
}
if (servo_type == "left_roll" || servo_type == "lr") {
return LEFT_ROLL;
}
if (servo_type == "body" || servo_type == "b") {
return BODY;
}
if (servo_type == "head" || servo_type == "h") {
return HEAD;
}
return -1;
}
static int ClampInt(int value, int min_value, int max_value) {
return std::max(min_value, std::min(max_value, value));
}
static int ServoMinAngle(int servo_index) {
switch (servo_index) {
case RIGHT_ROLL:
return 100;
case LEFT_ROLL:
return 0;
case BODY:
return 30;
case HEAD:
return 75;
default:
return 0;
}
}
static int ServoMaxAngle(int servo_index) {
switch (servo_index) {
case RIGHT_ROLL:
return 180;
case LEFT_ROLL:
return 80;
case BODY:
return 150;
case HEAD:
return 105;
default:
return 180;
}
}
static int ClampServoPosition(int servo_index, int position) {
return ClampInt(position, ServoMinAngle(servo_index), ServoMaxAngle(servo_index));
}
static void ClampOscillationRange(int amplitude[], int center_angle[]) {
for (int i = 0; i < SERVO_COUNT; i++) {
center_angle[i] = ClampServoPosition(i, center_angle[i]);
int max_safe_amplitude =
std::min(center_angle[i] - ServoMinAngle(i), ServoMaxAngle(i) - center_angle[i]);
amplitude[i] = ClampInt(amplitude[i], 0, std::max(0, max_safe_amplitude));
}
}
void ApplyServoObject(cJSON* servo_object, int values[], int min_value, int max_value,
bool use_servo_limits = false) {
if (!cJSON_IsObject(servo_object)) {
return;
}
cJSON* item = nullptr;
cJSON_ArrayForEach(item, servo_object) {
if (!cJSON_IsNumber(item) || item->string == nullptr) {
continue;
}
int servo_index = ServoIndexFromName(item->string);
if (servo_index >= 0) {
int value = ClampInt(item->valueint, min_value, max_value);
values[servo_index] = use_servo_limits ? ClampServoPosition(servo_index, value) : value;
}
}
}
void ExecuteServoSequence(const char* sequence_json) {
cJSON* json = cJSON_Parse(sequence_json);
if (json == nullptr) {
ESP_LOGE(TAG, "Failed to parse servo sequence JSON");
return;
}
cJSON* actions = cJSON_GetObjectItem(json, "a");
if (!cJSON_IsArray(actions)) {
ESP_LOGE(TAG, "Servo sequence requires array field 'a'");
cJSON_Delete(json);
return;
}
int current_positions[SERVO_COUNT];
electron_bot_.GetServoPositions(current_positions);
int action_count = cJSON_GetArraySize(actions);
for (int i = 0; i < action_count; i++) {
cJSON* action_item = cJSON_GetArrayItem(actions, i);
if (!cJSON_IsObject(action_item)) {
continue;
}
cJSON* osc_item = cJSON_GetObjectItem(action_item, "osc");
if (cJSON_IsObject(osc_item)) {
int amplitude[SERVO_COUNT] = {};
int center_angle[SERVO_COUNT];
double phase_diff[SERVO_COUNT] = {};
for (int j = 0; j < SERVO_COUNT; j++) {
center_angle[j] = current_positions[j];
}
ApplyServoObject(cJSON_GetObjectItem(osc_item, "a"), amplitude, 0, 90);
ApplyServoObject(cJSON_GetObjectItem(osc_item, "o"), center_angle, 0, 180, true);
ClampOscillationRange(amplitude, center_angle);
cJSON* phase_item = cJSON_GetObjectItem(osc_item, "ph");
if (cJSON_IsObject(phase_item)) {
cJSON* item = nullptr;
cJSON_ArrayForEach(item, phase_item) {
if (!cJSON_IsNumber(item) || item->string == nullptr) {
continue;
}
int servo_index = ServoIndexFromName(item->string);
if (servo_index >= 0) {
phase_diff[servo_index] = item->valuedouble * M_PI / 180.0;
}
}
}
int period = 500;
cJSON* period_item = cJSON_GetObjectItem(osc_item, "p");
if (cJSON_IsNumber(period_item)) {
period = ClampInt(period_item->valueint, 100, 3000);
}
float cycles = 5.0f;
cJSON* cycles_item = cJSON_GetObjectItem(osc_item, "c");
if (cJSON_IsNumber(cycles_item)) {
cycles =
std::max(0.1f, std::min(20.0f, static_cast<float>(cycles_item->valuedouble)));
}
electron_bot_.OscillateServos(amplitude, center_angle, period, phase_diff, cycles);
for (int j = 0; j < SERVO_COUNT; j++) {
current_positions[j] = center_angle[j];
}
} else {
int servo_target[SERVO_COUNT];
for (int j = 0; j < SERVO_COUNT; j++) {
servo_target[j] = current_positions[j];
}
ApplyServoObject(cJSON_GetObjectItem(action_item, "s"), servo_target, 0, 180, true);
int speed = 1000;
cJSON* speed_item = cJSON_GetObjectItem(action_item, "v");
if (cJSON_IsNumber(speed_item)) {
speed = ClampInt(speed_item->valueint, 100, 3000);
}
electron_bot_.MoveServos(speed, servo_target);
for (int j = 0; j < SERVO_COUNT; j++) {
current_positions[j] = servo_target[j];
}
}
int delay_after = 0;
cJSON* delay_item = cJSON_GetObjectItem(action_item, "d");
if (cJSON_IsNumber(delay_item)) {
delay_after = std::max(0, delay_item->valueint);
}
if (delay_after > 0 && i < action_count - 1) {
vTaskDelay(pdMS_TO_TICKS(delay_after));
}
}
cJSON* sequence_delay_item = cJSON_GetObjectItem(json, "d");
if (cJSON_IsNumber(sequence_delay_item)) {
int sequence_delay = std::max(0, sequence_delay_item->valueint);
if (sequence_delay > 0 && uxQueueMessagesWaiting(action_queue_) > 0) {
vTaskDelay(pdMS_TO_TICKS(sequence_delay));
}
}
cJSON_Delete(json);
}
static void ActionTask(void* arg) {
ElectronBotController* controller = static_cast<ElectronBotController*>(arg);
ElectronBotActionParams params;
@@ -94,14 +302,17 @@ private:
} else if (params.action_type == ACTION_HOME) {
// 复位动作
controller->electron_bot_.Home(true);
}
if (params.action_type != ACTION_HOME) {
UBaseType_t pending_actions =
uxQueueMessagesWaiting(controller->action_queue_);
// 连续动作时跳过中间归位,避免动作衔接生硬
if (pending_actions == 0) {
controller->electron_bot_.Home(true);
} else if (params.action_type == ACTION_SERVO_MOVE) {
int servo_target[SERVO_COUNT];
controller->electron_bot_.GetServoPositions(servo_target);
if (params.direction >= 0 && params.direction < SERVO_COUNT) {
servo_target[params.direction] =
ClampServoPosition(params.direction, params.amount);
controller->electron_bot_.MoveServos(ClampInt(params.speed, 100, 3000),
servo_target);
}
} else if (params.action_type == ACTION_SERVO_SEQUENCE) {
controller->ExecuteServoSequence(params.servo_sequence_json);
}
controller->is_action_in_progress_ = false; // 动作执行完毕
}
@@ -113,7 +324,27 @@ private:
ESP_LOGI(TAG, "动作控制: 类型=%d, 步数=%d, 速度=%d, 方向=%d, 幅度=%d", action_type, steps,
speed, direction, amount);
ElectronBotActionParams params = {action_type, steps, speed, direction, amount};
ElectronBotActionParams params = {};
params.action_type = action_type;
params.steps = steps;
params.speed = speed;
params.direction = direction;
params.amount = amount;
xQueueSend(action_queue_, &params, portMAX_DELAY);
StartActionTaskIfNeeded();
}
void QueueServoSequence(const char* servo_sequence_json) {
if (servo_sequence_json == nullptr || strlen(servo_sequence_json) == 0) {
ESP_LOGE(TAG, "Empty servo sequence");
return;
}
ElectronBotActionParams params = {};
params.action_type = ACTION_SERVO_SEQUENCE;
strncpy(params.servo_sequence_json, servo_sequence_json,
sizeof(params.servo_sequence_json) - 1);
params.servo_sequence_json[sizeof(params.servo_sequence_json) - 1] = '\0';
xQueueSend(action_queue_, &params, portMAX_DELAY);
StartActionTaskIfNeeded();
}
@@ -161,7 +392,7 @@ public:
"self.electron.hand_action",
"手部动作控制。action: 1=举手, 2=放手, 3=挥手, 4=拍打; hand: 1=左手, 2=右手, 3=双手; "
"steps: 动作重复次数(1-10); speed: 动作速度(500-1500数值越小越快); amount: "
"动作幅度(10-50仅举手动作使用)",
"拍打幅度(10-50固件会按安全范围裁剪)",
PropertyList({Property("action", kPropertyTypeInteger, 1, 1, 4),
Property("hand", kPropertyTypeInteger, 3, 1, 3),
Property("steps", kPropertyTypeInteger, 1, 1, 10),
@@ -190,7 +421,6 @@ public:
break; // 挥手
case 4:
base_action = ACTION_HAND_LEFT_FLAP;
amount = 0;
break; // 拍打
default:
base_action = ACTION_HAND_LEFT_UP;
@@ -254,16 +484,66 @@ public:
return true;
});
mcp_server.AddTool(
"self.electron.servo_move",
"单独调节 ElectronBot 任意舵机到指定绝对角度。servo_type 支持完整名 "
"right_pitch/right_roll/left_pitch/left_roll/body/head也支持短键 rp/rr/lp/lr/b/h"
"position 会按安全范围自动裁剪rp/lp=0-180, rr=100-180, lr=0-80, body=30-150, head=75-105"
"speed 为移动时间 100-3000 毫秒。",
PropertyList({Property("servo_type", kPropertyTypeString, "head"),
Property("position", kPropertyTypeInteger, 90, 0, 180),
Property("speed", kPropertyTypeInteger, 800, 100, 3000)}),
[this](const PropertyList& properties) -> ReturnValue {
std::string servo_type = properties["servo_type"].value<std::string>();
int servo_index = ServoIndexFromName(servo_type);
if (servo_index < 0) {
return "错误:无效的舵机类型,请使用 right_pitch/right_roll/left_pitch/left_roll/body/head 或 rp/rr/lp/lr/b/h";
}
int position = properties["position"].value<int>();
int speed = properties["speed"].value<int>();
QueueAction(ACTION_SERVO_MOVE, 1, speed, servo_index, position);
return true;
});
mcp_server.AddTool(
"self.electron.servo_sequences",
"AI 自编程动作序列。支持分段多次调用,每次发送一个短 JSON 序列并自动排队执行。"
"舵机短键rp=右臂pitch, rr=右臂roll, lp=左臂pitch, lr=左臂roll, b=身体旋转, h=头部。"
"所有舵机角度都会按安全范围裁剪rp/lp=0-180, rr=100-180, lr=0-80, b=30-150, h=75-105"
"振荡模式会自动限制振幅,保证中心角±振幅不越界。"
"格式sequence 是 JSON 字符串,顶层包含 a 动作数组,可选 d 为序列间延迟毫秒。"
"普通动作:{\"s\":{\"rp\":120,\"h\":100},\"v\":800,\"d\":200}s 是舵机目标角度 0-180v 是移动时间 100-3000ms。"
"振荡动作:{\"osc\":{\"a\":{\"rp\":20},\"o\":{\"rp\":120},\"ph\":{\"lp\":180},\"p\":500,\"c\":4}}"
"a 是振幅 0-90o 是中心角度 0-180ph 是相位角度p 是周期c 是周期数。"
"示例:{\"a\":[{\"s\":{\"rp\":120,\"lp\":60},\"v\":800},{\"osc\":{\"a\":{\"rr\":25,\"lr\":25},\"o\":{\"rr\":160,\"lr\":20},\"ph\":{\"lr\":180},\"p\":400,\"c\":5}}]}",
PropertyList({Property("sequence", kPropertyTypeString,
"{\"a\":[{\"s\":{\"h\":100},\"v\":800}]}")}),
[this](const PropertyList& properties) -> ReturnValue {
std::string sequence = properties["sequence"].value<std::string>();
QueueServoSequence(sequence.c_str());
return true;
});
// 系统工具
mcp_server.AddTool("self.electron.stop", "立即停止", PropertyList(),
[this](const PropertyList& properties) -> ReturnValue {
// 清空队列但保持任务常驻
if (action_task_handle_ != nullptr) {
vTaskDelete(action_task_handle_);
action_task_handle_ = nullptr;
}
xQueueReset(action_queue_);
is_action_in_progress_ = false;
QueueAction(ACTION_HOME, 1, 1000, 0, 0);
return true;
});
mcp_server.AddTool("self.electron.home", "复位到 ElectronBot 初始姿态", PropertyList(),
[this](const PropertyList& properties) -> ReturnValue {
QueueAction(ACTION_HOME, 1, 1000, 0, 0);
return true;
});
mcp_server.AddTool("self.electron.get_status", "获取机器人状态,返回 moving 或 idle",
PropertyList(), [this](const PropertyList& properties) -> ReturnValue {
return is_action_in_progress_ ? "moving" : "idle";
@@ -362,6 +642,16 @@ public:
return status;
});
mcp_server.AddTool("self.electron.get_ip", "获取 ElectronBot WiFi IP 地址", PropertyList(),
[](const PropertyList& properties) -> ReturnValue {
auto& wifi = WifiManager::GetInstance();
std::string ip = wifi.GetIpAddress();
if (ip.empty()) {
return "{\"ip\":\"\",\"connected\":false}";
}
return "{\"ip\":\"" + ip + "\",\"connected\":true}";
});
ESP_LOGI(TAG, "Electron Bot MCP工具注册完成");
}

View File

@@ -2,7 +2,6 @@
#include <algorithm>
#include <cmath>
#include <cstring>
#include "oscillator.h"
@@ -11,6 +10,46 @@ float EaseOutCubic(float t) {
float inv = 1.0f - t;
return 1.0f - inv * inv * inv;
}
int ServoMinAngle(int servo_index) {
switch (servo_index) {
case RIGHT_ROLL:
return 100;
case LEFT_ROLL:
return 0;
case BODY:
return 30;
case HEAD:
return 75;
default:
return 0;
}
}
int ServoMaxAngle(int servo_index) {
switch (servo_index) {
case RIGHT_ROLL:
return 180;
case LEFT_ROLL:
return 80;
case BODY:
return 150;
case HEAD:
return 105;
default:
return 180;
}
}
int ClampServoTarget(int servo_index, int position) {
return std::clamp(position, ServoMinAngle(servo_index), ServoMaxAngle(servo_index));
}
int ClampServoAmplitude(int servo_index, int center, int amplitude) {
int max_safe_amplitude =
std::min(center - ServoMinAngle(servo_index), ServoMaxAngle(servo_index) - center);
return std::clamp(amplitude, 0, std::max(0, max_safe_amplitude));
}
} // namespace
Otto::Otto() {
@@ -88,10 +127,15 @@ void Otto::MoveServos(int time, int servo_target[]) {
SetRestState(false);
}
int target[SERVO_COUNT];
for (int i = 0; i < SERVO_COUNT; i++) {
target[i] = ClampServoTarget(i, servo_target[i]);
}
if (time <= 10) {
for (int i = 0; i < SERVO_COUNT; i++) {
if (servo_pins_[i] != -1) {
servo_[i].SetPosition(servo_target[i]);
servo_[i].SetPosition(target[i]);
}
}
vTaskDelay(pdMS_TO_TICKS(time));
@@ -109,7 +153,7 @@ void Otto::MoveServos(int time, int servo_target[]) {
float eased_t = EaseOutCubic(t);
for (int i = 0; i < SERVO_COUNT; i++) {
if (servo_pins_[i] != -1) {
float interpolated = start[i] + (servo_target[i] - start[i]) * eased_t;
float interpolated = start[i] + (target[i] - start[i]) * eased_t;
servo_[i].SetPosition(static_cast<int>(std::round(interpolated)));
}
}
@@ -118,32 +162,40 @@ void Otto::MoveServos(int time, int servo_target[]) {
for (int i = 0; i < SERVO_COUNT; i++) {
if (servo_pins_[i] != -1) {
servo_[i].SetPosition(servo_target[i]);
servo_[i].SetPosition(target[i]);
}
}
}
void Otto::MoveSingle(int position, int servo_number) {
if (position > 180)
position = 90;
if (position < 0)
position = 90;
if (servo_number < 0 || servo_number >= SERVO_COUNT || servo_pins_[servo_number] == -1) {
return;
}
position = ClampServoTarget(servo_number, position);
if (GetRestState() == true) {
SetRestState(false);
}
if (servo_number >= 0 && servo_number < SERVO_COUNT && servo_pins_[servo_number] != -1) {
servo_[servo_number].SetPosition(position);
servo_[servo_number].SetPosition(position);
}
void Otto::GetServoPositions(int positions[]) {
for (int i = 0; i < SERVO_COUNT; i++) {
positions[i] = (servo_pins_[i] != -1) ? servo_[i].GetPosition() : servo_initial_[i];
}
}
void Otto::OscillateServos(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period,
double phase_diff[SERVO_COUNT], float cycle = 1) {
int center[SERVO_COUNT];
for (int i = 0; i < SERVO_COUNT; i++) {
if (servo_pins_[i] != -1) {
servo_[i].SetO(offset[i]);
servo_[i].SetA(amplitude[i]);
center[i] = ClampServoTarget(i, offset[i]);
int safe_amplitude = ClampServoAmplitude(i, center[i], amplitude[i]);
servo_[i].SetO(center[i] - 90);
servo_[i].SetA(safe_amplitude);
servo_[i].SetT(period);
servo_[i].SetPh(phase_diff[i]);
}
@@ -160,6 +212,11 @@ void Otto::OscillateServos(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT],
}
vTaskDelay(5);
}
for (int i = 0; i < SERVO_COUNT; i++) {
if (servo_pins_[i] != -1) {
servo_[i].SetPosition(center[i]);
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
@@ -226,12 +283,24 @@ void Otto::HandAction(int action, int times, int amount, int period) {
times = 2 * std::max(3, std::min(100, times));
amount = std::max(10, std::min(50, amount));
period = std::max(100, std::min(1000, period));
int flap_amount = std::min(amount, 40);
const int left_flap_center = 40;
const int right_flap_center = 140;
int current_positions[SERVO_COUNT];
for (int i = 0; i < SERVO_COUNT; i++) {
current_positions[i] = (servo_pins_[i] != -1) ? servo_[i].GetPosition() : servo_initial_[i];
}
auto lower_left_hand = [&]() {
current_positions[LEFT_PITCH] = servo_initial_[LEFT_PITCH];
current_positions[LEFT_ROLL] = servo_initial_[LEFT_ROLL];
};
auto lower_right_hand = [&]() {
current_positions[RIGHT_PITCH] = servo_initial_[RIGHT_PITCH];
current_positions[RIGHT_ROLL] = servo_initial_[RIGHT_ROLL];
};
switch (action) {
case 1: // 举左手
current_positions[LEFT_PITCH] = 180;
@@ -250,10 +319,18 @@ void Otto::HandAction(int action, int times, int amount, int period) {
break;
case 4: // 放左手
lower_left_hand();
MoveServos(period, current_positions);
break;
case 5: // 放右手
lower_right_hand();
MoveServos(period, current_positions);
break;
case 6: // 放双手
// 回到初始位置
memcpy(current_positions, servo_initial_, sizeof(current_positions));
lower_left_hand();
lower_right_hand();
MoveServos(period, current_positions);
break;
@@ -265,7 +342,7 @@ void Otto::HandAction(int action, int times, int amount, int period) {
MoveServos(period / 10, current_positions);
vTaskDelay(pdMS_TO_TICKS(period / 10));
}
memcpy(current_positions, servo_initial_, sizeof(current_positions));
current_positions[LEFT_PITCH] = servo_initial_[LEFT_PITCH];
MoveServos(period, current_positions);
break;
@@ -277,7 +354,7 @@ void Otto::HandAction(int action, int times, int amount, int period) {
MoveServos(period / 10, current_positions);
vTaskDelay(pdMS_TO_TICKS(period / 10));
}
memcpy(current_positions, servo_initial_, sizeof(current_positions));
current_positions[RIGHT_PITCH] = servo_initial_[RIGHT_PITCH];
MoveServos(period, current_positions);
break;
@@ -291,50 +368,51 @@ void Otto::HandAction(int action, int times, int amount, int period) {
MoveServos(period / 10, current_positions);
vTaskDelay(pdMS_TO_TICKS(period / 10));
}
memcpy(current_positions, servo_initial_, sizeof(current_positions));
current_positions[LEFT_PITCH] = servo_initial_[LEFT_PITCH];
current_positions[RIGHT_PITCH] = servo_initial_[RIGHT_PITCH];
MoveServos(period, current_positions);
break;
case 10: // 拍打左手
current_positions[LEFT_ROLL] = 20;
current_positions[LEFT_ROLL] = left_flap_center;
MoveServos(period, current_positions);
for (int i = 0; i < times; i++) {
current_positions[LEFT_ROLL] = 20 - amount;
current_positions[LEFT_ROLL] = left_flap_center - flap_amount;
MoveServos(period / 10, current_positions);
current_positions[LEFT_ROLL] = 20 + amount;
current_positions[LEFT_ROLL] = left_flap_center + flap_amount;
MoveServos(period / 10, current_positions);
}
current_positions[LEFT_ROLL] = 0;
current_positions[LEFT_ROLL] = servo_initial_[LEFT_ROLL];
MoveServos(period, current_positions);
break;
case 11: // 拍打右手
current_positions[RIGHT_ROLL] = 160;
current_positions[RIGHT_ROLL] = right_flap_center;
MoveServos(period, current_positions);
for (int i = 0; i < times; i++) {
current_positions[RIGHT_ROLL] = 160 + amount;
current_positions[RIGHT_ROLL] = right_flap_center + flap_amount;
MoveServos(period / 10, current_positions);
current_positions[RIGHT_ROLL] = 160 - amount;
current_positions[RIGHT_ROLL] = right_flap_center - flap_amount;
MoveServos(period / 10, current_positions);
}
current_positions[RIGHT_ROLL] = 180;
current_positions[RIGHT_ROLL] = servo_initial_[RIGHT_ROLL];
MoveServos(period, current_positions);
break;
case 12: // 拍打双手
current_positions[LEFT_ROLL] = 20;
current_positions[RIGHT_ROLL] = 160;
current_positions[LEFT_ROLL] = left_flap_center;
current_positions[RIGHT_ROLL] = right_flap_center;
MoveServos(period, current_positions);
for (int i = 0; i < times; i++) {
current_positions[LEFT_ROLL] = 20 - amount;
current_positions[RIGHT_ROLL] = 160 + amount;
current_positions[LEFT_ROLL] = left_flap_center - flap_amount;
current_positions[RIGHT_ROLL] = right_flap_center + flap_amount;
MoveServos(period / 10, current_positions);
current_positions[LEFT_ROLL] = 20 + amount;
current_positions[RIGHT_ROLL] = 160 - amount;
current_positions[LEFT_ROLL] = left_flap_center + flap_amount;
current_positions[RIGHT_ROLL] = right_flap_center - flap_amount;
MoveServos(period / 10, current_positions);
}
current_positions[LEFT_ROLL] = 0;
current_positions[RIGHT_ROLL] = 180;
current_positions[LEFT_ROLL] = servo_initial_[LEFT_ROLL];
current_positions[RIGHT_ROLL] = servo_initial_[RIGHT_ROLL];
MoveServos(period, current_positions);
break;
}

View File

@@ -48,6 +48,7 @@ public:
//-- Predetermined Motion Functions
void MoveServos(int time, int servo_target[]);
void MoveSingle(int position, int servo_number);
void GetServoPositions(int positions[]);
void OscillateServos(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period,
double phase_diff[SERVO_COUNT], float cycle);
@@ -86,4 +87,4 @@ private:
double phase_diff[SERVO_COUNT], float steps);
};
#endif // __MOVEMENTS_H__
#endif // __MOVEMENTS_H__

View File

@@ -0,0 +1,250 @@
#include "websocket_control_server.h"
#include "mcp_server.h"
#include <cJSON.h>
#include <esp_log.h>
#include <cstdlib>
#include <cstring>
static const char* TAG = "ElectronBotWS";
WebSocketControlServer* WebSocketControlServer::instance_ = nullptr;
WebSocketControlServer::WebSocketControlServer() : server_handle_(nullptr) {
instance_ = this;
}
WebSocketControlServer::~WebSocketControlServer() {
Stop();
instance_ = nullptr;
}
esp_err_t WebSocketControlServer::WsHandler(httpd_req_t* req) {
if (instance_ == nullptr) {
return ESP_FAIL;
}
if (req->method == HTTP_GET) {
ESP_LOGI(TAG, "WebSocket handshake completed");
instance_->AddClient(req);
return ESP_OK;
}
httpd_ws_frame_t ws_pkt = {};
ws_pkt.type = HTTPD_WS_TYPE_TEXT;
esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, 0);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to read WebSocket frame length: %d", ret);
return ret;
}
uint8_t* buf = nullptr;
if (ws_pkt.len > 0) {
buf = static_cast<uint8_t*>(calloc(1, ws_pkt.len + 1));
if (buf == nullptr) {
ESP_LOGE(TAG, "Failed to allocate WebSocket frame buffer");
return ESP_ERR_NO_MEM;
}
ws_pkt.payload = buf;
ret = httpd_ws_recv_frame(req, &ws_pkt, ws_pkt.len);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to read WebSocket frame payload: %d", ret);
free(buf);
return ret;
}
}
if (ws_pkt.type == HTTPD_WS_TYPE_CLOSE) {
ESP_LOGI(TAG, "WebSocket close frame received");
instance_->RemoveClient(req);
free(buf);
return ESP_OK;
}
if (ws_pkt.type == HTTPD_WS_TYPE_TEXT) {
if (ws_pkt.len > 0 && buf != nullptr) {
buf[ws_pkt.len] = '\0';
instance_->HandleMessage(req, reinterpret_cast<const char*>(buf), ws_pkt.len);
}
} else {
ESP_LOGW(TAG, "Unsupported WebSocket frame type: %d", ws_pkt.type);
}
free(buf);
return ESP_OK;
}
bool WebSocketControlServer::Start(int port) {
if (server_handle_ != nullptr) {
return true;
}
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = port;
config.max_open_sockets = 7;
config.ctrl_port = 32769;
httpd_uri_t ws_uri = {
.uri = "/ws",
.method = HTTP_GET,
.handler = WsHandler,
.user_ctx = nullptr,
.is_websocket = true,
};
if (httpd_start(&server_handle_, &config) == ESP_OK) {
httpd_register_uri_handler(server_handle_, &ws_uri);
ESP_LOGI(TAG, "WebSocket control server started on port %d", port);
return true;
}
ESP_LOGE(TAG, "Failed to start WebSocket control server");
return false;
}
void WebSocketControlServer::Stop() {
if (server_handle_ != nullptr) {
httpd_stop(server_handle_);
server_handle_ = nullptr;
clients_.clear();
ESP_LOGI(TAG, "WebSocket control server stopped");
}
}
void WebSocketControlServer::HandleMessage(httpd_req_t* req, const char* data, size_t len) {
(void)req;
if (data == nullptr || len == 0) {
ESP_LOGE(TAG, "Invalid empty WebSocket message");
return;
}
if (len > 4096) {
ESP_LOGE(TAG, "WebSocket message too long: %zu bytes", len);
return;
}
char* temp_buf = static_cast<char*>(malloc(len + 1));
if (temp_buf == nullptr) {
ESP_LOGE(TAG, "Failed to allocate JSON parse buffer");
return;
}
memcpy(temp_buf, data, len);
temp_buf[len] = '\0';
cJSON* root = cJSON_Parse(temp_buf);
free(temp_buf);
if (root == nullptr) {
ESP_LOGE(TAG, "Failed to parse WebSocket JSON message");
return;
}
cJSON* payload = nullptr;
cJSON* type = cJSON_GetObjectItem(root, "type");
if (type != nullptr && cJSON_IsString(type) && strcmp(type->valuestring, "mcp") == 0) {
payload = cJSON_GetObjectItem(root, "payload");
if (payload != nullptr) {
cJSON_DetachItemViaPointer(root, payload);
McpServer::GetInstance().ParseMessage(payload);
cJSON_Delete(payload);
}
} else {
payload = cJSON_Duplicate(root, 1);
if (payload != nullptr) {
McpServer::GetInstance().ParseMessage(payload);
cJSON_Delete(payload);
}
}
if (payload == nullptr) {
ESP_LOGE(TAG, "Invalid WebSocket message format");
}
cJSON_Delete(root);
}
void WebSocketControlServer::AddClient(httpd_req_t* req) {
int sock_fd = httpd_req_to_sockfd(req);
if (clients_.find(sock_fd) == clients_.end()) {
clients_[sock_fd] = req;
ESP_LOGI(TAG, "WebSocket client connected: %d (total: %zu)", sock_fd, clients_.size());
}
}
void WebSocketControlServer::RemoveClient(httpd_req_t* req) {
int sock_fd = httpd_req_to_sockfd(req);
clients_.erase(sock_fd);
ESP_LOGI(TAG, "WebSocket client disconnected: %d (total: %zu)", sock_fd, clients_.size());
}
size_t WebSocketControlServer::GetClientCount() const {
return clients_.size();
}
struct WsBroadcastJob {
httpd_handle_t server;
int fd;
char* payload;
size_t len;
};
static void WsBroadcastSendJob(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(TAG, "Failed to broadcast WebSocket message fd=%d err=%d", job->fd, ret);
}
free(job->payload);
free(job);
}
void WebSocketControlServer::BroadcastMessage(const std::string& message) {
if (server_handle_ == nullptr || clients_.empty()) {
return;
}
for (auto& [fd, req] : clients_) {
(void)req;
WsBroadcastJob* job = static_cast<WsBroadcastJob*>(malloc(sizeof(WsBroadcastJob)));
if (job == nullptr) {
ESP_LOGE(TAG, "Failed to allocate WebSocket broadcast 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 == nullptr) {
ESP_LOGE(TAG, "Failed to allocate WebSocket broadcast 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_, WsBroadcastSendJob, job);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to queue WebSocket broadcast fd=%d err=%d", fd, ret);
free(job->payload);
free(job);
}
}
}

View File

@@ -0,0 +1,33 @@
#ifndef WEBSOCKET_CONTROL_SERVER_H
#define WEBSOCKET_CONTROL_SERVER_H
#include <esp_http_server.h>
#include <map>
#include <string>
class WebSocketControlServer {
public:
WebSocketControlServer();
~WebSocketControlServer();
bool Start(int port = 8080);
void Stop();
size_t GetClientCount() const;
void BroadcastMessage(const std::string& message);
private:
httpd_handle_t server_handle_;
std::map<int, httpd_req_t*> clients_;
static esp_err_t WsHandler(httpd_req_t* req);
void HandleMessage(httpd_req_t* req, const char* data, size_t len);
void AddClient(httpd_req_t* req);
void RemoveClient(httpd_req_t* req);
static WebSocketControlServer* instance_;
};
#endif // WEBSOCKET_CONTROL_SERVER_H