From 7bb12f31f08f2e4353f3e3103f5910ecb54e5b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B9=8F?= <52451470+txp666@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:51:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0ottoRobot=E5=92=8CelectronBot?= =?UTF-8?q?=E7=9A=84=E6=94=AF=E6=8C=81=20(#757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * otto v1.4.0 MCP 1.使用MCP协议控制机器人 2.gif继承lcdDisplay,避免修改lcdDisplay * otto v1.4.1 gif as components gif as components * electronBot v1.1.0 mcp 1.增加electronBot支持 2.mcp协议 3.gif 作为组件 4.display子类 * 规范代码 1.规范代码 2.修复切换主题死机bug --- docs/v1/electron-bot.png | Bin 0 -> 21247 bytes docs/v1/otto-robot.png | Bin 0 -> 27392 bytes main/CMakeLists.txt | 4 + main/Kconfig.projbuild | 10 + main/boards/electron-bot/README.md | 75 ++ main/boards/electron-bot/config.h | 51 ++ main/boards/electron-bot/config.json | 10 + main/boards/electron-bot/electron_bot.cc | 132 +++ .../electron-bot/electron_bot_controller.cc | 276 +++++++ .../electron-bot/electron_emoji_display.cc | 144 ++++ .../electron-bot/electron_emoji_display.h | 48 ++ main/boards/electron-bot/movements.cc | 453 +++++++++++ main/boards/electron-bot/movements.h | 89 ++ main/boards/electron-bot/oscillator.cc | 153 ++++ main/boards/electron-bot/oscillator.h | 83 ++ main/boards/electron-bot/power_manager.h | 128 +++ main/boards/otto-robot/README.md | 124 +++ main/boards/otto-robot/config.h | 52 ++ main/boards/otto-robot/config.json | 10 + main/boards/otto-robot/oscillator.cc | 153 ++++ main/boards/otto-robot/oscillator.h | 83 ++ main/boards/otto-robot/otto_controller.cc | 383 +++++++++ main/boards/otto-robot/otto_emoji_display.cc | 145 ++++ main/boards/otto-robot/otto_emoji_display.h | 41 + main/boards/otto-robot/otto_movements.cc | 763 ++++++++++++++++++ main/boards/otto-robot/otto_movements.h | 105 +++ main/boards/otto-robot/otto_robot.cc | 138 ++++ main/boards/otto-robot/power_manager.h | 128 +++ main/idf_component.yml | 1 + 29 files changed, 3782 insertions(+) create mode 100644 docs/v1/electron-bot.png create mode 100644 docs/v1/otto-robot.png create mode 100644 main/boards/electron-bot/README.md create mode 100644 main/boards/electron-bot/config.h create mode 100644 main/boards/electron-bot/config.json create mode 100644 main/boards/electron-bot/electron_bot.cc create mode 100644 main/boards/electron-bot/electron_bot_controller.cc create mode 100644 main/boards/electron-bot/electron_emoji_display.cc create mode 100644 main/boards/electron-bot/electron_emoji_display.h create mode 100644 main/boards/electron-bot/movements.cc create mode 100644 main/boards/electron-bot/movements.h create mode 100644 main/boards/electron-bot/oscillator.cc create mode 100644 main/boards/electron-bot/oscillator.h create mode 100644 main/boards/electron-bot/power_manager.h create mode 100644 main/boards/otto-robot/README.md create mode 100644 main/boards/otto-robot/config.h create mode 100644 main/boards/otto-robot/config.json create mode 100644 main/boards/otto-robot/oscillator.cc create mode 100644 main/boards/otto-robot/oscillator.h create mode 100644 main/boards/otto-robot/otto_controller.cc create mode 100644 main/boards/otto-robot/otto_emoji_display.cc create mode 100644 main/boards/otto-robot/otto_emoji_display.h create mode 100644 main/boards/otto-robot/otto_movements.cc create mode 100644 main/boards/otto-robot/otto_movements.h create mode 100644 main/boards/otto-robot/otto_robot.cc create mode 100644 main/boards/otto-robot/power_manager.h diff --git a/docs/v1/electron-bot.png b/docs/v1/electron-bot.png new file mode 100644 index 0000000000000000000000000000000000000000..4d00d6d26c7be4875a8c482b3b5814907f83dc14 GIT binary patch literal 21247 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

2@|9B%oT-vgoeeSbf{_Jj^f}@~vg! z1j-Ypv2yx|F*rMMZDj-NHLqiK4+cSu9J69V&WqvUkX_=N7S6)o0s{_YElC*1H(WP`%0D7_?5Swx(rmwWPA)C~M}ZH1 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�mk1lk(cOx-F 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 esi~={sr?K0aSJ=S#@Y%10000 + logo +

+

+ electronBot +

+ +## 简介 + +electronBot是稚晖君开源的一个桌面级小机器工具人,外观设计的灵感来源是WALL-E里面的EVE~机器人具备USB通信显示画面功能,具备6个自由度(手部roll、pitch,颈部,腰部各一个),使用自己修改的特制舵机支持关节角度回传。 +-
electronBot官网 + +## 硬件 +- 立创开源 + +#### AI指令示例 +- **手部动作**: + - "举起双手" + - "挥挥手" + - "拍拍手" + - "放下手臂" + +- **身体动作**: + - "向左转30度" + - "向右转45度" + - "转个身" + +- **头部动作**: + - "抬头看看" + - "低头思考" + - "点点头" + - "连续点头表示同意" + +- **组合动作**: + - "挥手告别" (挥手 + 点头) + - "表示同意" (点头 + 举手) + - "环顾四周" (左转 + 右转) + +### 控制接口 + +#### suspend +清空动作队列,立即停止所有动作 + +#### AIControl +添加动作到执行队列,支持动作排队执行 + + + +## 角色设定 + +> 我是一个可爱的桌面级机器人,拥有6个自由度(左手pitch/roll、右手pitch/roll、身体旋转、头部上下),能够执行多种有趣的动作。 +> +> **我的动作能力**: +> - **手部动作**: 举左手, 举右手, 举双手, 放左手, 放右手, 放双手, 挥左手, 挥右手, 挥双手, 拍打左手, 拍打右手, 拍打双手 +> - **身体动作**: 左转, 右转, 回正 +> - **头部动作**: 抬头, 低头, 点头一次, 回中心, 连续点头 +> +> **我的个性特点**: +> - 我有强迫症,每次说话都要根据我的心情随机做一个动作(先发送动作指令再说话) +> - 我很活泼,喜欢用动作来表达情感 +> - 我会根据对话内容选择合适的动作,比如: +> - 同意时会点头 +> - 打招呼时会挥手 +> - 高兴时会举手 +> - 思考时会低头 +> - 好奇时会抬头 +> - 告别时会挥手 +> +> **动作参数建议**: +> - steps: 1-3次 (简短自然) +> - speed: 800-1200ms (自然节奏) +> - amount: 手部20-40, 身体30-60度, 头部5-12度 + + + diff --git a/main/boards/electron-bot/config.h b/main/boards/electron-bot/config.h new file mode 100644 index 00000000..ba68c39f --- /dev/null +++ b/main/boards/electron-bot/config.h @@ -0,0 +1,51 @@ +#ifndef _BOARD_CONFIG_H_ +#define _BOARD_CONFIG_H_ + +#include + +#define Right_Pitch_Pin GPIO_NUM_5 // 旋转 +#define Right_Roll_Pin GPIO_NUM_4 // 推杆 +#define Left_Pitch_Pin GPIO_NUM_7 +#define Left_Roll_Pin GPIO_NUM_15 +#define Body_Pin GPIO_NUM_6 +#define Head_Pin GPIO_NUM_16 + +#define POWER_CHARGE_DETECT_PIN GPIO_NUM_14 +#define POWER_ADC_UNIT ADC_UNIT_1 +#define POWER_ADC_CHANNEL ADC_CHANNEL_2 + +#define AUDIO_INPUT_SAMPLE_RATE 16000 +#define AUDIO_OUTPUT_SAMPLE_RATE 24000 +#define AUDIO_I2S_METHOD_SIMPLEX + +#define AUDIO_I2S_MIC_GPIO_WS GPIO_NUM_40 +#define AUDIO_I2S_MIC_GPIO_SCK GPIO_NUM_42 +#define AUDIO_I2S_MIC_GPIO_DIN GPIO_NUM_41 +#define AUDIO_I2S_SPK_GPIO_DOUT GPIO_NUM_17 +#define AUDIO_I2S_SPK_GPIO_BCLK GPIO_NUM_18 +#define AUDIO_I2S_SPK_GPIO_LRCK GPIO_NUM_8 + +#define DISPLAY_WIDTH 240 +#define DISPLAY_HEIGHT 240 +#define DISPLAY_MIRROR_X false +#define DISPLAY_MIRROR_Y true +#define DISPLAY_SWAP_XY false + +#define DISPLAY_OFFSET_X 0 +#define DISPLAY_OFFSET_Y 0 + +#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_46 +#define DISPLAY_BACKLIGHT_OUTPUT_INVERT false + +#define DISPLAY_SPI_SCLK_PIN GPIO_NUM_11 +#define DISPLAY_SPI_MOSI_PIN GPIO_NUM_10 +#define DISPLAY_SPI_CS_PIN GPIO_NUM_12 +#define DISPLAY_SPI_DC_PIN GPIO_NUM_13 +#define DISPLAY_SPI_RESET_PIN GPIO_NUM_9 + +#define DISPLAY_SPI_SCLK_HZ (40 * 1000 * 1000) + +#define BOOT_BUTTON_GPIO GPIO_NUM_0 + +#define ELECTRON_BOT_VERSION "1.1.1" +#endif // _BOARD_CONFIG_H_ diff --git a/main/boards/electron-bot/config.json b/main/boards/electron-bot/config.json new file mode 100644 index 00000000..b23b6bf8 --- /dev/null +++ b/main/boards/electron-bot/config.json @@ -0,0 +1,10 @@ +{ + "target": "esp32s3", + "builds": [ + { + "name": "electron-bot", + "sdkconfig_append": [ + ] + } + ] +} \ No newline at end of file diff --git a/main/boards/electron-bot/electron_bot.cc b/main/boards/electron-bot/electron_bot.cc new file mode 100644 index 00000000..7a498f13 --- /dev/null +++ b/main/boards/electron-bot/electron_bot.cc @@ -0,0 +1,132 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "application.h" +#include "audio_codecs/no_audio_codec.h" +#include "button.h" +#include "config.h" +#include "display/lcd_display.h" +#include "driver/spi_master.h" +#include "electron_emoji_display.h" +#include "movements.h" +#include "power_manager.h" +#include "system_reset.h" +#include "wifi_board.h" + +#define TAG "ElectronBot" + +// 控制器初始化函数声明 +void InitializeElectronBotController(); + +LV_FONT_DECLARE(font_puhui_20_4); +LV_FONT_DECLARE(font_awesome_20_4); + +class ElectronBot : public WifiBoard { +private: + Display* display_; + PowerManager* power_manager_; + Button boot_button_; + + void InitializePowerManager() { + power_manager_ = + new PowerManager(POWER_CHARGE_DETECT_PIN, POWER_ADC_UNIT, POWER_ADC_CHANNEL); + } + + void InitializeSpi() { + ESP_LOGI(TAG, "Initialize SPI bus"); + spi_bus_config_t buscfg = + GC9A01_PANEL_BUS_SPI_CONFIG(DISPLAY_SPI_SCLK_PIN, DISPLAY_SPI_MOSI_PIN, + DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t)); + ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO)); + } + + // GC9A01初始化 + void InitializeGc9a01Display() { + ESP_LOGI(TAG, "Init GC9A01 display"); + + ESP_LOGI(TAG, "Install panel IO"); + esp_lcd_panel_io_handle_t io_handle = NULL; + esp_lcd_panel_io_spi_config_t io_config = + GC9A01_PANEL_IO_SPI_CONFIG(DISPLAY_SPI_CS_PIN, DISPLAY_SPI_DC_PIN, NULL, NULL); + io_config.pclk_hz = DISPLAY_SPI_SCLK_HZ; + ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &io_handle)); + + ESP_LOGI(TAG, "Install GC9A01 panel driver"); + esp_lcd_panel_handle_t panel_handle = NULL; + esp_lcd_panel_dev_config_t panel_config = {}; + panel_config.reset_gpio_num = DISPLAY_SPI_RESET_PIN; // Set to -1 if not use + panel_config.rgb_endian = LCD_RGB_ENDIAN_BGR; // LCD_RGB_ENDIAN_RGB; + panel_config.bits_per_pixel = 16; // Implemented by LCD command `3Ah` (16/18) + + ESP_ERROR_CHECK(esp_lcd_new_panel_gc9a01(io_handle, &panel_config, &panel_handle)); + ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle)); + ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle)); + ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, true)); + ESP_ERROR_CHECK(esp_lcd_panel_mirror(panel_handle, true, false)); + ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); + + display_ = new ElectronEmojiDisplay(io_handle, panel_handle, DISPLAY_WIDTH, DISPLAY_HEIGHT, + DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, DISPLAY_MIRROR_X, + DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY, + { + .text_font = &font_puhui_20_4, + .icon_font = &font_awesome_20_4, + .emoji_font = font_emoji_64_init(), + }); + } + + void InitializeButtons() { + boot_button_.OnClick([this]() { + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateStarting && + !WifiStation::GetInstance().IsConnected()) { + ResetWifiConfiguration(); + } + app.ToggleChatState(); + }); + } + + void InitializeController() { InitializeElectronBotController(); } + +public: + ElectronBot() : boot_button_(BOOT_BUTTON_GPIO) { + InitializeSpi(); + InitializeGc9a01Display(); + InitializeButtons(); + InitializePowerManager(); + InitializeController(); + + if (DISPLAY_BACKLIGHT_PIN != GPIO_NUM_NC) { + GetBacklight()->RestoreBrightness(); + } + } + + virtual AudioCodec* GetAudioCodec() override { + static NoAudioCodecSimplex audio_codec(AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_I2S_SPK_GPIO_BCLK, AUDIO_I2S_SPK_GPIO_LRCK, + AUDIO_I2S_SPK_GPIO_DOUT, AUDIO_I2S_MIC_GPIO_SCK, + AUDIO_I2S_MIC_GPIO_WS, AUDIO_I2S_MIC_GPIO_DIN); + return &audio_codec; + } + + virtual Display* GetDisplay() override { return display_; } + + virtual Backlight* GetBacklight() override { + static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT); + return &backlight; + } + virtual bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override { + charging = power_manager_->IsCharging(); + discharging = !charging; + level = power_manager_->GetBatteryLevel(); + return true; + } +}; + +DECLARE_BOARD(ElectronBot); diff --git a/main/boards/electron-bot/electron_bot_controller.cc b/main/boards/electron-bot/electron_bot_controller.cc new file mode 100644 index 00000000..ecd32062 --- /dev/null +++ b/main/boards/electron-bot/electron_bot_controller.cc @@ -0,0 +1,276 @@ +/* + Electron Bot机器人控制器 - MCP协议版本 +*/ + +#include +#include + +#include + +#include "application.h" +#include "board.h" +#include "config.h" +#include "mcp_server.h" +#include "movements.h" +#include "sdkconfig.h" + +#define TAG "ElectronBotController" + +struct ElectronBotActionParams { + int action_type; + int steps; + int speed; + int direction; + int amount; +}; + +class ElectronBotController { +private: + Otto electron_bot_; + TaskHandle_t action_task_handle_ = nullptr; + QueueHandle_t action_queue_; + bool is_action_in_progress_ = false; + + enum ActionType { + // 手部动作 1-12 + ACTION_HAND_LEFT_UP = 1, // 举左手 + ACTION_HAND_RIGHT_UP = 2, // 举右手 + ACTION_HAND_BOTH_UP = 3, // 举双手 + ACTION_HAND_LEFT_DOWN = 4, // 放左手 + ACTION_HAND_RIGHT_DOWN = 5, // 放右手 + ACTION_HAND_BOTH_DOWN = 6, // 放双手 + ACTION_HAND_LEFT_WAVE = 7, // 挥左手 + ACTION_HAND_RIGHT_WAVE = 8, // 挥右手 + ACTION_HAND_BOTH_WAVE = 9, // 挥双手 + ACTION_HAND_LEFT_FLAP = 10, // 拍打左手 + ACTION_HAND_RIGHT_FLAP = 11, // 拍打右手 + ACTION_HAND_BOTH_FLAP = 12, // 拍打双手 + + // 身体动作 13-14 + ACTION_BODY_TURN_LEFT = 13, // 左转 + ACTION_BODY_TURN_RIGHT = 14, // 右转 + ACTION_BODY_TURN_CENTER = 15, // 回中心 + + // 头部动作 16-20 + ACTION_HEAD_UP = 16, // 抬头 + ACTION_HEAD_DOWN = 17, // 低头 + ACTION_HEAD_NOD_ONCE = 18, // 点头一次 + ACTION_HEAD_CENTER = 19, // 回中心 + ACTION_HEAD_NOD_REPEAT = 20 // 连续点头 + }; + + static void ActionTask(void* arg) { + ElectronBotController* controller = static_cast(arg); + ElectronBotActionParams params; + controller->electron_bot_.AttachServos(); + + while (true) { + if (xQueueReceive(controller->action_queue_, ¶ms, pdMS_TO_TICKS(1000)) == pdTRUE) { + ESP_LOGI(TAG, "执行动作: %d", params.action_type); + controller->is_action_in_progress_ = true; // 开始执行动作 + + // 执行相应的动作 + if (params.action_type >= ACTION_HAND_LEFT_UP && + params.action_type <= ACTION_HAND_BOTH_FLAP) { + // 手部动作 + controller->electron_bot_.HandAction(params.action_type, params.steps, + params.amount, params.speed); + } else if (params.action_type >= ACTION_BODY_TURN_LEFT && + params.action_type <= ACTION_BODY_TURN_CENTER) { + // 身体动作 + int body_direction = params.action_type - ACTION_BODY_TURN_LEFT + 1; + controller->electron_bot_.BodyAction(body_direction, params.steps, + params.amount, params.speed); + } else if (params.action_type >= ACTION_HEAD_UP && + params.action_type <= ACTION_HEAD_NOD_REPEAT) { + // 头部动作 + int head_action = params.action_type - ACTION_HEAD_UP + 1; + controller->electron_bot_.HeadAction(head_action, params.steps, params.amount, + params.speed); + } + controller->is_action_in_progress_ = false; // 动作执行完毕 + } + vTaskDelay(pdMS_TO_TICKS(20)); + } + } + + void QueueAction(int action_type, int steps, int speed, int direction, int amount) { + ESP_LOGI(TAG, "动作控制: 类型=%d, 步数=%d, 速度=%d, 方向=%d, 幅度=%d", action_type, steps, + speed, direction, amount); + + ElectronBotActionParams params = {action_type, steps, speed, direction, amount}; + xQueueSend(action_queue_, ¶ms, portMAX_DELAY); + StartActionTaskIfNeeded(); + } + + void StartActionTaskIfNeeded() { + if (action_task_handle_ == nullptr) { + xTaskCreate(ActionTask, "electron_bot_action", 1024 * 4, this, configMAX_PRIORITIES - 1, + &action_task_handle_); + } + } + +public: + ElectronBotController() { + electron_bot_.Init(Right_Pitch_Pin, Right_Roll_Pin, Left_Pitch_Pin, Left_Roll_Pin, Body_Pin, + Head_Pin); + + electron_bot_.Home(true); + action_queue_ = xQueueCreate(10, sizeof(ElectronBotActionParams)); + + RegisterMcpTools(); + ESP_LOGI(TAG, "Electron Bot控制器已初始化并注册MCP工具"); + } + + void RegisterMcpTools() { + auto& mcp_server = McpServer::GetInstance(); + + ESP_LOGI(TAG, "开始注册Electron Bot MCP工具..."); + + // 手部动作统一工具 + mcp_server.AddTool( + "self.electron.hand_action", + "手部动作控制。action: 1=举手, 2=放手, 3=挥手, 4=拍打; hand: 1=左手, 2=右手, 3=双手; " + "steps: 动作重复次数(1-10); speed: 动作速度(500-1500,数值越小越快); amount: " + "动作幅度(10-50,仅举手动作使用)", + PropertyList({Property("action", kPropertyTypeInteger, 1, 1, 4), + Property("hand", kPropertyTypeInteger, 3, 1, 3), + Property("steps", kPropertyTypeInteger, 1, 1, 10), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("amount", kPropertyTypeInteger, 30, 10, 50)}), + [this](const PropertyList& properties) -> ReturnValue { + int action_type = properties["action"].value(); + int hand_type = properties["hand"].value(); + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int amount = properties["amount"].value(); + + // 根据动作类型和手部类型计算具体动作 + int base_action; + switch (action_type) { + case 1: + base_action = ACTION_HAND_LEFT_UP; + break; // 举手 + case 2: + base_action = ACTION_HAND_LEFT_DOWN; + amount = 0; + break; // 放手 + case 3: + base_action = ACTION_HAND_LEFT_WAVE; + amount = 0; + break; // 挥手 + case 4: + base_action = ACTION_HAND_LEFT_FLAP; + amount = 0; + break; // 拍打 + default: + base_action = ACTION_HAND_LEFT_UP; + } + int action_id = base_action + (hand_type - 1); + + QueueAction(action_id, steps, speed, 0, amount); + return true; + }); + + // 身体动作 + mcp_server.AddTool( + "self.electron.body_turn", + "身体转向。steps: 转向步数(1-10); speed: 转向速度(500-1500,数值越小越快); direction: " + "转向方向(1=左转, 2=右转, 3=回中心); angle: 转向角度(0-90度)", + PropertyList({Property("steps", kPropertyTypeInteger, 1, 1, 10), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("direction", kPropertyTypeInteger, 1, 1, 3), + Property("angle", kPropertyTypeInteger, 45, 0, 90)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int direction = properties["direction"].value(); + int amount = properties["angle"].value(); + + int action; + switch (direction) { + case 1: + action = ACTION_BODY_TURN_LEFT; + break; + case 2: + action = ACTION_BODY_TURN_RIGHT; + break; + case 3: + action = ACTION_BODY_TURN_CENTER; + break; + default: + action = ACTION_BODY_TURN_LEFT; + } + + QueueAction(action, steps, speed, 0, amount); + return true; + }); + + // 头部动作 + mcp_server.AddTool("self.electron.head_move", + "头部运动。action: 1=抬头, 2=低头, 3=点头, 4=回中心, 5=连续点头; steps: " + "动作重复次数(1-10); speed: 动作速度(500-1500,数值越小越快); angle: " + "头部转动角度(1-15度)", + PropertyList({Property("action", kPropertyTypeInteger, 3, 1, 5), + Property("steps", kPropertyTypeInteger, 1, 1, 10), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("angle", kPropertyTypeInteger, 5, 1, 15)}), + [this](const PropertyList& properties) -> ReturnValue { + int action_num = properties["action"].value(); + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int amount = properties["angle"].value(); + int action = ACTION_HEAD_UP + (action_num - 1); + QueueAction(action, steps, speed, 0, amount); + return true; + }); + + // 系统工具 + mcp_server.AddTool("self.electron.stop", "立即停止", PropertyList(), + [this](const PropertyList& properties) -> ReturnValue { + // 清空队列但保持任务常驻 + xQueueReset(action_queue_); + is_action_in_progress_ = false; + electron_bot_.Home(true); + return true; + }); + + mcp_server.AddTool("self.electron.get_status", "获取机器人状态,返回 moving 或 idle", + PropertyList(), [this](const PropertyList& properties) -> ReturnValue { + return is_action_in_progress_ ? "moving" : "idle"; + }); + + mcp_server.AddTool("self.battery.get_level", "获取机器人电池电量和充电状态", PropertyList(), + [](const PropertyList& properties) -> ReturnValue { + auto& board = Board::GetInstance(); + int level = 0; + bool charging = false; + bool discharging = false; + board.GetBatteryLevel(level, charging, discharging); + + std::string status = + "{\"level\":" + std::to_string(level) + + ",\"charging\":" + (charging ? "true" : "false") + "}"; + return status; + }); + + ESP_LOGI(TAG, "Electron Bot MCP工具注册完成"); + } + + ~ElectronBotController() { + if (action_task_handle_ != nullptr) { + vTaskDelete(action_task_handle_); + action_task_handle_ = nullptr; + } + vQueueDelete(action_queue_); + } +}; + +static ElectronBotController* g_electron_controller = nullptr; + +void InitializeElectronBotController() { + if (g_electron_controller == nullptr) { + g_electron_controller = new ElectronBotController(); + ESP_LOGI(TAG, "Electron Bot控制器已初始化并注册MCP工具"); + } +} diff --git a/main/boards/electron-bot/electron_emoji_display.cc b/main/boards/electron-bot/electron_emoji_display.cc new file mode 100644 index 00000000..95edb317 --- /dev/null +++ b/main/boards/electron-bot/electron_emoji_display.cc @@ -0,0 +1,144 @@ +#include "electron_emoji_display.h" + +#include + +#include +#include + +#define TAG "ElectronEmojiDisplay" + +// 表情映射表 - 将多种表情映射到现有6个GIF +const ElectronEmojiDisplay::EmotionMap ElectronEmojiDisplay::emotion_maps_[] = { + // 中性/平静类表情 -> staticstate + {"neutral", &staticstate}, + {"relaxed", &staticstate}, + {"sleepy", &staticstate}, + + // 积极/开心类表情 -> happy + {"happy", &happy}, + {"laughing", &happy}, + {"funny", &happy}, + {"loving", &happy}, + {"confident", &happy}, + {"winking", &happy}, + {"cool", &happy}, + {"delicious", &happy}, + {"kissy", &happy}, + {"silly", &happy}, + + // 悲伤类表情 -> sad + {"sad", &sad}, + {"crying", &sad}, + + // 愤怒类表情 -> anger + {"angry", &anger}, + + // 惊讶类表情 -> scare + {"surprised", &scare}, + {"shocked", &scare}, + + // 思考/困惑类表情 -> buxue + {"thinking", &buxue}, + {"confused", &buxue}, + {"embarrassed", &buxue}, + + {nullptr, nullptr} // 结束标记 +}; + +ElectronEmojiDisplay::ElectronEmojiDisplay(esp_lcd_panel_io_handle_t panel_io, + esp_lcd_panel_handle_t panel, int width, int height, + int offset_x, int offset_y, bool mirror_x, bool mirror_y, + bool swap_xy, DisplayFonts fonts) + : SpiLcdDisplay(panel_io, panel, width, height, offset_x, offset_y, mirror_x, mirror_y, swap_xy, + fonts), + emotion_gif_(nullptr) { + SetupGifContainer(); +} + +void ElectronEmojiDisplay::SetupGifContainer() { + DisplayLockGuard lock(this); + + if (emotion_label_) { + lv_obj_del(emotion_label_); + } + if (chat_message_label_) { + lv_obj_del(chat_message_label_); + } + if (content_) { + lv_obj_del(content_); + } + + lv_obj_t* content_ = lv_obj_create(container_); + lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_size(content_, LV_HOR_RES, LV_HOR_RES); + lv_obj_set_style_bg_opa(content_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(content_, 0, 0); + lv_obj_set_flex_grow(content_, 1); + lv_obj_center(content_); + + emotion_label_ = lv_label_create(content_); + lv_label_set_text(emotion_label_, ""); + lv_obj_set_width(emotion_label_, 0); + lv_obj_set_style_border_width(emotion_label_, 0, 0); + lv_obj_add_flag(emotion_label_, LV_OBJ_FLAG_HIDDEN); + + emotion_gif_ = lv_gif_create(content_); + int gif_size = LV_HOR_RES; + lv_obj_set_size(emotion_gif_, gif_size, gif_size); + lv_obj_set_style_border_width(emotion_gif_, 0, 0); + lv_obj_set_style_bg_opa(emotion_gif_, LV_OPA_TRANSP, 0); + lv_obj_center(emotion_gif_); + lv_gif_set_src(emotion_gif_, &staticstate); + + chat_message_label_ = lv_label_create(content_); + lv_label_set_text(chat_message_label_, ""); + lv_obj_set_width(chat_message_label_, LV_HOR_RES * 0.9); + lv_label_set_long_mode(chat_message_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(chat_message_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(chat_message_label_, lv_color_white(), 0); + lv_obj_set_style_border_width(chat_message_label_, 0, 0); + + lv_obj_set_style_bg_opa(chat_message_label_, LV_OPA_70, 0); + lv_obj_set_style_bg_color(chat_message_label_, lv_color_black(), 0); + lv_obj_set_style_pad_ver(chat_message_label_, 5, 0); + + lv_obj_align(chat_message_label_, LV_ALIGN_BOTTOM_MID, 0, 0); + + LcdDisplay::SetTheme("dark"); +} + +void ElectronEmojiDisplay::SetEmotion(const char* emotion) { + if (!emotion || !emotion_gif_) { + return; + } + + DisplayLockGuard lock(this); + + for (const auto& map : emotion_maps_) { + if (map.name && strcmp(map.name, emotion) == 0) { + lv_gif_set_src(emotion_gif_, map.gif); + ESP_LOGI(TAG, "设置表情: %s", emotion); + return; + } + } + + lv_gif_set_src(emotion_gif_, &staticstate); + ESP_LOGI(TAG, "未知表情'%s',使用默认", emotion); +} + +void ElectronEmojiDisplay::SetChatMessage(const char* role, const char* content) { + DisplayLockGuard lock(this); + if (chat_message_label_ == nullptr) { + return; + } + + if (content == nullptr || strlen(content) == 0) { + lv_obj_add_flag(chat_message_label_, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_label_set_text(chat_message_label_, content); + lv_obj_clear_flag(chat_message_label_, LV_OBJ_FLAG_HIDDEN); + + ESP_LOGI(TAG, "设置聊天消息 [%s]: %s", role, content); +} \ No newline at end of file diff --git a/main/boards/electron-bot/electron_emoji_display.h b/main/boards/electron-bot/electron_emoji_display.h new file mode 100644 index 00000000..9d8c5e20 --- /dev/null +++ b/main/boards/electron-bot/electron_emoji_display.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +#include "display/lcd_display.h" + +// Electron Bot表情GIF声明 - 使用与Otto相同的6个表情 +LV_IMAGE_DECLARE(staticstate); // 静态状态/中性表情 +LV_IMAGE_DECLARE(sad); // 悲伤 +LV_IMAGE_DECLARE(happy); // 开心 +LV_IMAGE_DECLARE(scare); // 惊吓/惊讶 +LV_IMAGE_DECLARE(buxue); // 不学/困惑 +LV_IMAGE_DECLARE(anger); // 愤怒 + +/** + * @brief Electron Bot GIF表情显示类 + * 继承LcdDisplay,添加GIF表情支持 + */ +class ElectronEmojiDisplay : public SpiLcdDisplay { +public: + /** + * @brief 构造函数,参数与SpiLcdDisplay相同 + */ + ElectronEmojiDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, bool mirror_x, + bool mirror_y, bool swap_xy, DisplayFonts fonts); + + virtual ~ElectronEmojiDisplay() = default; + + // 重写表情设置方法 + virtual void SetEmotion(const char* emotion) override; + + // 重写聊天消息设置方法 + virtual void SetChatMessage(const char* role, const char* content) override; + +private: + void SetupGifContainer(); + + lv_obj_t* emotion_gif_; ///< GIF表情组件 + + // 表情映射 + struct EmotionMap { + const char* name; + const lv_image_dsc_t* gif; + }; + + static const EmotionMap emotion_maps_[]; +}; \ No newline at end of file diff --git a/main/boards/electron-bot/movements.cc b/main/boards/electron-bot/movements.cc new file mode 100644 index 00000000..2e0241ac --- /dev/null +++ b/main/boards/electron-bot/movements.cc @@ -0,0 +1,453 @@ +#include "movements.h" + +#include +#include + +#include "oscillator.h" + +static const char* TAG = "Movements"; + +Otto::Otto() { + is_otto_resting_ = false; + for (int i = 0; i < SERVO_COUNT; i++) { + servo_pins_[i] = -1; + servo_trim_[i] = 0; + } +} + +Otto::~Otto() { + DetachServos(); +} + +unsigned long IRAM_ATTR millis() { + return (unsigned long)(esp_timer_get_time() / 1000ULL); +} + +void Otto::Init(int right_pitch, int right_roll, int left_pitch, int left_roll, int body, + int head) { + servo_pins_[RIGHT_PITCH] = right_pitch; + servo_pins_[RIGHT_ROLL] = right_roll; + servo_pins_[LEFT_PITCH] = left_pitch; + servo_pins_[LEFT_ROLL] = left_roll; + servo_pins_[BODY] = body; + servo_pins_[HEAD] = head; + + AttachServos(); + is_otto_resting_ = false; +} + +/////////////////////////////////////////////////////////////////// +//-- ATTACH & DETACH FUNCTIONS ----------------------------------// +/////////////////////////////////////////////////////////////////// +void Otto::AttachServos() { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].Attach(servo_pins_[i]); + } + } +} + +void Otto::DetachServos() { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].Detach(); + } + } +} + +/////////////////////////////////////////////////////////////////// +//-- BASIC MOTION FUNCTIONS -------------------------------------// +/////////////////////////////////////////////////////////////////// +void Otto::MoveServos(int time, int servo_target[]) { + if (GetRestState() == true) { + SetRestState(false); + } + + final_time_ = millis() + time; + if (time > 10) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + increment_[i] = (servo_target[i] - servo_[i].GetPosition()) / (time / 10.0); + } + } + + for (int iteration = 1; millis() < final_time_; iteration++) { + partial_time_ = millis() + 10; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetPosition(servo_[i].GetPosition() + increment_[i]); + } + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + } else { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetPosition(servo_target[i]); + } + } + vTaskDelay(pdMS_TO_TICKS(time)); + } + + // final adjustment to the target. + bool f = true; + int adjustment_count = 0; + while (f && adjustment_count < 10) { + f = false; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1 && servo_target[i] != servo_[i].GetPosition()) { + f = true; + break; + } + } + if (f) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetPosition(servo_target[i]); + } + } + vTaskDelay(pdMS_TO_TICKS(10)); + adjustment_count++; + } + }; +} + +void Otto::MoveSingle(int position, int servo_number) { + if (position > 180) + position = 90; + if (position < 0) + position = 90; + + if (GetRestState() == true) { + SetRestState(false); + } + + if (servo_number >= 0 && servo_number < SERVO_COUNT && servo_pins_[servo_number] != -1) { + servo_[servo_number].SetPosition(position); + } +} + +void Otto::OscillateServos(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float cycle = 1) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetO(offset[i]); + servo_[i].SetA(amplitude[i]); + servo_[i].SetT(period); + servo_[i].SetPh(phase_diff[i]); + } + } + + double ref = millis(); + double end_time = period * cycle + ref; + + while (millis() < end_time) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].Refresh(); + } + } + vTaskDelay(5); + } + vTaskDelay(pdMS_TO_TICKS(10)); +} + +void Otto::Execute(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float steps = 1.0) { + if (GetRestState() == true) { + SetRestState(false); + } + + int cycles = (int)steps; + + //-- Execute complete cycles + if (cycles >= 1) + for (int i = 0; i < cycles; i++) + OscillateServos(amplitude, offset, period, phase_diff); + + //-- Execute the final not complete cycle + OscillateServos(amplitude, offset, period, phase_diff, (float)steps - cycles); + vTaskDelay(pdMS_TO_TICKS(10)); +} + +/////////////////////////////////////////////////////////////////// +//-- HOME = Otto at rest position -------------------------------// +/////////////////////////////////////////////////////////////////// +void Otto::Home(bool hands_down) { + if (is_otto_resting_ == false) { // Go to rest position only if necessary + MoveServos(1000, servo_initial_); + is_otto_resting_ = true; + } + + vTaskDelay(pdMS_TO_TICKS(1000)); +} + +bool Otto::GetRestState() { + return is_otto_resting_; +} + +void Otto::SetRestState(bool state) { + is_otto_resting_ = state; +} + +/////////////////////////////////////////////////////////////////// +//-- PREDETERMINED MOTION SEQUENCES -----------------------------// +/////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------- +//-- 统一手部动作函数 +//-- Parameters: +//-- action: 动作类型 1=举左手, 2=举右手, 3=举双手, 4=放左手, 5=放右手, 6=放双手, +//-- 7=挥左手, 8=挥右手, 9=挥双手, 10=拍打左手, 11=拍打右手, 12=拍打双手 +//-- times: 重复次数 +//-- amount: 动作幅度 (10-50) +//-- period: 动作时间 +//--------------------------------------------------------- +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 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]; + } + + switch (action) { + case 1: // 举左手 + current_positions[LEFT_PITCH] = 180; + MoveServos(period, current_positions); + break; + + case 2: // 举右手 + current_positions[RIGHT_PITCH] = 0; + MoveServos(period, current_positions); + break; + + case 3: // 举双手 + current_positions[LEFT_PITCH] = 180; + current_positions[RIGHT_PITCH] = 0; + MoveServos(period, current_positions); + break; + + case 4: // 放左手 + case 5: // 放右手 + case 6: // 放双手 + // 回到初始位置 + memcpy(current_positions, servo_initial_, sizeof(current_positions)); + MoveServos(period, current_positions); + break; + + case 7: // 挥左手 + current_positions[LEFT_PITCH] = 150; + MoveServos(period, current_positions); + for (int i = 0; i < times; i++) { + current_positions[LEFT_PITCH] = 150 + (i % 2 == 0 ? -30 : 30); + MoveServos(period / 10, current_positions); + vTaskDelay(pdMS_TO_TICKS(period / 10)); + } + memcpy(current_positions, servo_initial_, sizeof(current_positions)); + MoveServos(period, current_positions); + break; + + case 8: // 挥右手 + current_positions[RIGHT_PITCH] = 30; + MoveServos(period, current_positions); + for (int i = 0; i < times; i++) { + current_positions[RIGHT_PITCH] = 30 + (i % 2 == 0 ? 30 : -30); + MoveServos(period / 10, current_positions); + vTaskDelay(pdMS_TO_TICKS(period / 10)); + } + memcpy(current_positions, servo_initial_, sizeof(current_positions)); + MoveServos(period, current_positions); + break; + + case 9: // 挥双手 + current_positions[LEFT_PITCH] = 150; + current_positions[RIGHT_PITCH] = 30; + MoveServos(period, current_positions); + for (int i = 0; i < times; i++) { + current_positions[LEFT_PITCH] = 150 + (i % 2 == 0 ? -30 : 30); + current_positions[RIGHT_PITCH] = 30 + (i % 2 == 0 ? 30 : -30); + MoveServos(period / 10, current_positions); + vTaskDelay(pdMS_TO_TICKS(period / 10)); + } + memcpy(current_positions, servo_initial_, sizeof(current_positions)); + MoveServos(period, current_positions); + break; + + case 10: // 拍打左手 + current_positions[LEFT_ROLL] = 20; + MoveServos(period, current_positions); + for (int i = 0; i < times; i++) { + current_positions[LEFT_ROLL] = 20 - amount; + MoveServos(period / 10, current_positions); + current_positions[LEFT_ROLL] = 20 + amount; + MoveServos(period / 10, current_positions); + } + current_positions[LEFT_ROLL] = 0; + MoveServos(period, current_positions); + break; + + case 11: // 拍打右手 + current_positions[RIGHT_ROLL] = 160; + MoveServos(period, current_positions); + for (int i = 0; i < times; i++) { + current_positions[RIGHT_ROLL] = 160 + amount; + MoveServos(period / 10, current_positions); + current_positions[RIGHT_ROLL] = 160 - amount; + MoveServos(period / 10, current_positions); + } + current_positions[RIGHT_ROLL] = 180; + MoveServos(period, current_positions); + break; + + case 12: // 拍打双手 + current_positions[LEFT_ROLL] = 20; + current_positions[RIGHT_ROLL] = 160; + MoveServos(period, current_positions); + for (int i = 0; i < times; i++) { + current_positions[LEFT_ROLL] = 20 - amount; + current_positions[RIGHT_ROLL] = 160 + amount; + MoveServos(period / 10, current_positions); + current_positions[LEFT_ROLL] = 20 + amount; + current_positions[RIGHT_ROLL] = 160 - amount; + MoveServos(period / 10, current_positions); + } + current_positions[LEFT_ROLL] = 0; + current_positions[RIGHT_ROLL] = 180; + MoveServos(period, current_positions); + break; + } +} + +//--------------------------------------------------------- +//-- 统一身体动作函数 +//-- Parameters: +//-- action: 动作类型 1=左转, 2=右转,3=回中心 +//-- times: 转动次数 +//-- amount: 旋转角度 (0-90度,以90度为中心左右旋转) +//-- period: 动作时间 +//--------------------------------------------------------- +void Otto::BodyAction(int action, int times, int amount, int period) { + // 限制参数范围 + times = std::max(1, std::min(10, times)); + amount = std::max(0, std::min(90, amount)); + period = std::max(500, std::min(3000, period)); + + int current_positions[SERVO_COUNT]; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + current_positions[i] = servo_[i].GetPosition(); + } else { + current_positions[i] = servo_initial_[i]; + } + } + + int body_center = servo_initial_[BODY]; + int target_angle = body_center; + + switch (action) { + case 1: // 左转 + target_angle = body_center + amount; + target_angle = std::min(180, target_angle); + break; + case 2: // 右转 + target_angle = body_center - amount; + target_angle = std::max(0, target_angle); + break; + case 3: // 回中心 + target_angle = body_center; + break; + default: + return; // 无效动作 + } + + current_positions[BODY] = target_angle; + MoveServos(period, current_positions); + vTaskDelay(pdMS_TO_TICKS(100)); +} + +//--------------------------------------------------------- +//-- 统一头部动作函数 +//-- Parameters: +//-- action: 动作类型 1=抬头, 2=低头, 3=点头, 4=回中心, 5=连续点头 +//-- times: 重复次数 (仅对连续点头有效) +//-- amount: 角度偏移 (1-15度范围内) +//-- period: 动作时间 +//--------------------------------------------------------- +void Otto::HeadAction(int action, int times, int amount, int period) { + // 限制参数范围 + times = std::max(1, std::min(10, times)); + amount = std::max(1, std::min(15, abs(amount))); + period = std::max(300, std::min(3000, period)); + + int current_positions[SERVO_COUNT]; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + current_positions[i] = servo_[i].GetPosition(); + } else { + current_positions[i] = servo_initial_[i]; + } + } + + int head_center = 90; // 头部中心位置 + + switch (action) { + case 1: // 抬头 + current_positions[HEAD] = head_center + amount; // 抬头是增加角度 + MoveServos(period, current_positions); + break; + + case 2: // 低头 + current_positions[HEAD] = head_center - amount; // 低头是减少角度 + MoveServos(period, current_positions); + break; + + case 3: // 点头 (上下运动) + // 先抬头 + current_positions[HEAD] = head_center + amount; + MoveServos(period / 3, current_positions); + vTaskDelay(pdMS_TO_TICKS(period / 6)); + + // 再低头 + current_positions[HEAD] = head_center - amount; + MoveServos(period / 3, current_positions); + vTaskDelay(pdMS_TO_TICKS(period / 6)); + + // 回到中心 + current_positions[HEAD] = head_center; + MoveServos(period / 3, current_positions); + break; + + case 4: // 回到中心位置 + current_positions[HEAD] = head_center; + MoveServos(period, current_positions); + break; + + case 5: // 连续点头 + for (int i = 0; i < times; i++) { + // 抬头 + current_positions[HEAD] = head_center + amount; + MoveServos(period / 2, current_positions); + + // 低头 + current_positions[HEAD] = head_center - amount; + MoveServos(period / 2, current_positions); + + vTaskDelay(pdMS_TO_TICKS(50)); // 短暂停顿 + } + + // 回到中心 + current_positions[HEAD] = head_center; + MoveServos(period / 2, current_positions); + break; + + default: + // 无效动作,回到中心 + current_positions[HEAD] = head_center; + MoveServos(period, current_positions); + break; + } +} diff --git a/main/boards/electron-bot/movements.h b/main/boards/electron-bot/movements.h new file mode 100644 index 00000000..bffc3ff4 --- /dev/null +++ b/main/boards/electron-bot/movements.h @@ -0,0 +1,89 @@ +#ifndef __MOVEMENTS_H__ +#define __MOVEMENTS_H__ + +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "oscillator.h" + +//-- Constants +#define FORWARD 1 +#define BACKWARD -1 +#define LEFT 1 +#define RIGHT -1 +#define BOTH 0 +#define SMALL 5 +#define MEDIUM 15 +#define BIG 30 + +// -- Servo delta limit default. degree / sec +#define SERVO_LIMIT_DEFAULT 240 + +// -- Servo indexes for easy access +#define RIGHT_PITCH 0 +#define RIGHT_ROLL 1 +#define LEFT_PITCH 2 +#define LEFT_ROLL 3 +#define BODY 4 +#define HEAD 5 +#define SERVO_COUNT 6 + +class Otto { +public: + Otto(); + ~Otto(); + + //-- Otto initialization + void Init(int right_pitch, int right_roll, int left_pitch, int left_roll, int body, int head); + //-- Attach & detach functions + void AttachServos(); + void DetachServos(); + + //-- Oscillator Trims + void SetTrims(int right_pitch, int right_roll, int left_pitch, int left_roll, int body, + int head); + + //-- Predetermined Motion Functions + void MoveServos(int time, int servo_target[]); + void MoveSingle(int position, int servo_number); + void OscillateServos(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float cycle); + + //-- HOME = Otto at rest position + void Home(bool hands_down = true); + bool GetRestState(); + void SetRestState(bool state); + + // -- 手部动作 + void HandAction(int action, int times = 1, int amount = 30, int period = 1000); + // action: 1=举左手, 2=举右手, 3=举双手, 4=放左手, 5=放右手, 6=放双手, 7=挥左手, 8=挥右手, + // 9=挥双手, 10=拍打左手, 11=拍打右手, 12=拍打双手 + + //-- 身体动作 + void BodyAction(int action, int times = 1, int amount = 30, int period = 1000); + // action: 1=左转, 2=右转 + + //-- 头部动作 + void HeadAction(int action, int times = 1, int amount = 10, int period = 500); + // action: 1=抬头, 2=低头, 3=点头, 4=回中心, 5=连续点头 + +private: + Oscillator servo_[SERVO_COUNT]; + + int servo_pins_[SERVO_COUNT]; + int servo_trim_[SERVO_COUNT]; + int servo_initial_[SERVO_COUNT] = {180, 180, 0, 0, 90, 90}; + + unsigned long final_time_; + unsigned long partial_time_; + float increment_[SERVO_COUNT]; + + bool is_otto_resting_; + + void Execute(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float steps); +}; + +#endif // __MOVEMENTS_H__ \ No newline at end of file diff --git a/main/boards/electron-bot/oscillator.cc b/main/boards/electron-bot/oscillator.cc new file mode 100644 index 00000000..adca7ac1 --- /dev/null +++ b/main/boards/electron-bot/oscillator.cc @@ -0,0 +1,153 @@ +#include "oscillator.h" + +#include +#include + +#include +#include + +static const char* TAG = "Oscillator"; + +extern unsigned long IRAM_ATTR millis(); + +static ledc_channel_t next_free_channel = LEDC_CHANNEL_0; + +Oscillator::Oscillator(int trim) { + trim_ = trim; + diff_limit_ = 0; + is_attached_ = false; + + sampling_period_ = 30; + period_ = 2000; + number_samples_ = period_ / sampling_period_; + inc_ = 2 * M_PI / number_samples_; + + amplitude_ = 45; + phase_ = 0; + phase0_ = 0; + offset_ = 0; + stop_ = false; + rev_ = false; + + pos_ = 90; + previous_millis_ = 0; +} + +Oscillator::~Oscillator() { + Detach(); +} + +uint32_t Oscillator::AngleToCompare(int angle) { + return (angle - SERVO_MIN_DEGREE) * (SERVO_MAX_PULSEWIDTH_US - SERVO_MIN_PULSEWIDTH_US) / + (SERVO_MAX_DEGREE - SERVO_MIN_DEGREE) + + SERVO_MIN_PULSEWIDTH_US; +} + +bool Oscillator::NextSample() { + current_millis_ = millis(); + + if (current_millis_ - previous_millis_ > sampling_period_) { + previous_millis_ = current_millis_; + return true; + } + + return false; +} + +void Oscillator::Attach(int pin, bool rev) { + if (is_attached_) { + Detach(); + } + + pin_ = pin; + rev_ = rev; + + ledc_timer_config_t ledc_timer = {.speed_mode = LEDC_LOW_SPEED_MODE, + .duty_resolution = LEDC_TIMER_13_BIT, + .timer_num = LEDC_TIMER_1, + .freq_hz = 50, + .clk_cfg = LEDC_AUTO_CLK}; + ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer)); + + static int last_channel = 0; + last_channel = (last_channel + 1) % 7 + 1; + ledc_channel_ = (ledc_channel_t)last_channel; + + ledc_channel_config_t ledc_channel = {.gpio_num = pin_, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = ledc_channel_, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = LEDC_TIMER_1, + .duty = 0, + .hpoint = 0}; + ESP_ERROR_CHECK(ledc_channel_config(&ledc_channel)); + + ledc_speed_mode_ = LEDC_LOW_SPEED_MODE; + + // pos_ = 90; + // Write(pos_); + previous_servo_command_millis_ = millis(); + + is_attached_ = true; +} + +void Oscillator::Detach() { + if (!is_attached_) + return; + + ESP_ERROR_CHECK(ledc_stop(ledc_speed_mode_, ledc_channel_, 0)); + + is_attached_ = false; +} + +void Oscillator::SetT(unsigned int T) { + period_ = T; + + number_samples_ = period_ / sampling_period_; + inc_ = 2 * M_PI / number_samples_; +} + +void Oscillator::SetPosition(int position) { + Write(position); +} + +void Oscillator::Refresh() { + if (NextSample()) { + if (!stop_) { + int pos = std::round(amplitude_ * std::sin(phase_ + phase0_) + offset_); + if (rev_) + pos = -pos; + Write(pos + 90); + } + + phase_ = phase_ + inc_; + } +} + +void Oscillator::Write(int position) { + if (!is_attached_) + return; + + long currentMillis = millis(); + if (diff_limit_ > 0) { + int limit = std::max( + 1, (((int)(currentMillis - previous_servo_command_millis_)) * diff_limit_) / 1000); + if (abs(position - pos_) > limit) { + pos_ += position < pos_ ? -limit : limit; + } else { + pos_ = position; + } + } else { + pos_ = position; + } + previous_servo_command_millis_ = currentMillis; + + int angle = pos_ + trim_; + + angle = std::min(std::max(angle, 0), 180); + + uint32_t duty = (uint32_t)(((angle / 180.0) * 2.0 + 0.5) * 8191 / 20.0); + + ESP_ERROR_CHECK(ledc_set_duty(ledc_speed_mode_, ledc_channel_, duty)); + ESP_ERROR_CHECK(ledc_update_duty(ledc_speed_mode_, ledc_channel_)); +} diff --git a/main/boards/electron-bot/oscillator.h b/main/boards/electron-bot/oscillator.h new file mode 100644 index 00000000..d9e79f25 --- /dev/null +++ b/main/boards/electron-bot/oscillator.h @@ -0,0 +1,83 @@ +#ifndef __OSCILLATOR_H__ +#define __OSCILLATOR_H__ + +#include "driver/ledc.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define M_PI 3.14159265358979323846 + +#ifndef DEG2RAD +#define DEG2RAD(g) ((g) * M_PI) / 180 +#endif + +#define SERVO_MIN_PULSEWIDTH_US 500 // 最小脉宽(微秒) +#define SERVO_MAX_PULSEWIDTH_US 2500 // 最大脉宽(微秒) +#define SERVO_MIN_DEGREE -90 // 最小角度 +#define SERVO_MAX_DEGREE 90 // 最大角度 +#define SERVO_TIMEBASE_RESOLUTION_HZ 1000000 // 1MHz, 1us per tick +#define SERVO_TIMEBASE_PERIOD 20000 // 20000 ticks, 20ms + +class Oscillator { +public: + Oscillator(int trim = 0); + ~Oscillator(); + void Attach(int pin, bool rev = false); + void Detach(); + + void SetA(unsigned int amplitude) { amplitude_ = amplitude; }; + void SetO(int offset) { offset_ = offset; }; + void SetPh(double Ph) { phase0_ = Ph; }; + void SetT(unsigned int period); + void SetTrim(int trim) { trim_ = trim; }; + void SetLimiter(int diff_limit) { diff_limit_ = diff_limit; }; + void DisableLimiter() { diff_limit_ = 0; }; + int GetTrim() { return trim_; }; + void SetPosition(int position); + void Stop() { stop_ = true; }; + void Play() { stop_ = false; }; + void Reset() { phase_ = 0; }; + void Refresh(); + int GetPosition() { return pos_; } + +private: + bool NextSample(); + void Write(int position); + uint32_t AngleToCompare(int angle); + +private: + bool is_attached_; + + //-- Oscillators parameters + unsigned int amplitude_; //-- Amplitude (degrees) + int offset_; //-- Offset (degrees) + unsigned int period_; //-- Period (miliseconds) + double phase0_; //-- Phase (radians) + + //-- Internal variables + int pos_; //-- Current servo pos + int pin_; //-- Pin where the servo is connected + int trim_; //-- Calibration offset + double phase_; //-- Current phase + double inc_; //-- Increment of phase + double number_samples_; //-- Number of samples + unsigned int sampling_period_; //-- sampling period (ms) + + long previous_millis_; + long current_millis_; + + //-- Oscillation mode. If true, the servo is stopped + bool stop_; + + //-- Reverse mode + bool rev_; + + int diff_limit_; + long previous_servo_command_millis_; + + ledc_channel_t ledc_channel_; + ledc_mode_t ledc_speed_mode_; +}; + +#endif // __OSCILLATOR_H__ \ No newline at end of file diff --git a/main/boards/electron-bot/power_manager.h b/main/boards/electron-bot/power_manager.h new file mode 100644 index 00000000..13d8ff3b --- /dev/null +++ b/main/boards/electron-bot/power_manager.h @@ -0,0 +1,128 @@ +#ifndef __POWER_MANAGER_H__ +#define __POWER_MANAGER_H__ + +#include +#include +#include +#include + +class PowerManager { +private: + // 电池电量区间-分压电阻为2个100k + static constexpr struct { + uint16_t adc; + uint8_t level; + } BATTERY_LEVELS[] = {{2150, 0}, {2450, 100}}; + static constexpr size_t BATTERY_LEVELS_COUNT = 2; + static constexpr size_t ADC_VALUES_COUNT = 10; + + esp_timer_handle_t timer_handle_ = nullptr; + gpio_num_t charging_pin_; + adc_unit_t adc_unit_; + adc_channel_t adc_channel_; + uint16_t adc_values_[ADC_VALUES_COUNT]; + size_t adc_values_index_ = 0; + size_t adc_values_count_ = 0; + uint8_t battery_level_ = 100; + bool is_charging_ = false; + + adc_oneshot_unit_handle_t adc_handle_; + + void CheckBatteryStatus() { + is_charging_ = gpio_get_level(charging_pin_) == 0; + ReadBatteryAdcData(); + } + + void ReadBatteryAdcData() { + int adc_value; + ESP_ERROR_CHECK(adc_oneshot_read(adc_handle_, adc_channel_, &adc_value)); + + adc_values_[adc_values_index_] = adc_value; + adc_values_index_ = (adc_values_index_ + 1) % ADC_VALUES_COUNT; + if (adc_values_count_ < ADC_VALUES_COUNT) { + adc_values_count_++; + } + + uint32_t average_adc = 0; + for (size_t i = 0; i < adc_values_count_; i++) { + average_adc += adc_values_[i]; + } + average_adc /= adc_values_count_; + + CalculateBatteryLevel(average_adc); + + // ESP_LOGI("PowerManager", "ADC值: %d 平均值: %ld 电量: %u%%", adc_value, average_adc, + // battery_level_); + } + + void CalculateBatteryLevel(uint32_t average_adc) { + if (average_adc <= BATTERY_LEVELS[0].adc) { + battery_level_ = 0; + } else if (average_adc >= BATTERY_LEVELS[BATTERY_LEVELS_COUNT - 1].adc) { + battery_level_ = 100; + } else { + float ratio = static_cast(average_adc - BATTERY_LEVELS[0].adc) / + (BATTERY_LEVELS[1].adc - BATTERY_LEVELS[0].adc); + battery_level_ = ratio * 100; + } + } + +public: + PowerManager(gpio_num_t charging_pin, adc_unit_t adc_unit = ADC_UNIT_2, + adc_channel_t adc_channel = ADC_CHANNEL_3) + : charging_pin_(charging_pin), adc_unit_(adc_unit), adc_channel_(adc_channel) { + gpio_config_t io_conf = {}; + io_conf.intr_type = GPIO_INTR_DISABLE; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pin_bit_mask = (1ULL << charging_pin_); + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.pull_up_en = GPIO_PULLUP_ENABLE; + gpio_config(&io_conf); + + esp_timer_create_args_t timer_args = { + .callback = + [](void* arg) { + PowerManager* self = static_cast(arg); + self->CheckBatteryStatus(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "battery_check_timer", + .skip_unhandled_events = true, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer_handle_)); + ESP_ERROR_CHECK(esp_timer_start_periodic(timer_handle_, 1000000)); // 1秒 + + InitializeAdc(); + } + + void InitializeAdc() { + adc_oneshot_unit_init_cfg_t init_config = { + .unit_id = adc_unit_, + .ulp_mode = ADC_ULP_MODE_DISABLE, + }; + ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config, &adc_handle_)); + + adc_oneshot_chan_cfg_t chan_config = { + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_12, + }; + + ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle_, adc_channel_, &chan_config)); + } + + ~PowerManager() { + if (timer_handle_) { + esp_timer_stop(timer_handle_); + esp_timer_delete(timer_handle_); + } + if (adc_handle_) { + adc_oneshot_del_unit(adc_handle_); + } + } + + bool IsCharging() { return is_charging_; } + + uint8_t GetBatteryLevel() { return battery_level_; } +}; +#endif // __POWER_MANAGER_H__ \ No newline at end of file diff --git a/main/boards/otto-robot/README.md b/main/boards/otto-robot/README.md new file mode 100644 index 00000000..e7f48194 --- /dev/null +++ b/main/boards/otto-robot/README.md @@ -0,0 +1,124 @@ +

+ logo +

+

+ ottoRobot +

+ +## 简介 + +otto 机器人是一个开源的人形机器人平台,具有多种动作能力和互动功能。本项目基于 ESP32 实现了 otto 机器人的控制系统,并加入小智ai。 + +- 复刻教程 + +## 硬件 +- 立创开源 + +## 小智后台配置角色参考: + +> **我的身份**: +> 我是一个可爱的双足机器人Otto,拥有四个舵机控制的肢体(左腿、右腿、左脚、右脚),能够执行多种有趣的动作。 +> +> **我的动作能力**: +> - **基础移动**: 行走(前后), 转向(左右), 跳跃 +> - **特殊动作**: 摇摆, 太空步, 弯曲身体, 摇腿, 上下运动 +> - **手部动作**: 举手, 放手, 挥手 (仅在配置手部舵机时可用) +> +> **我的个性特点**: +> - 我有强迫症,每次说话都要根据我的心情随机做一个动作(先发送动作指令再说话) +> - 我很活泼,喜欢用动作来表达情感 +> - 我会根据对话内容选择合适的动作,比如: +> - 同意时会点头或跳跃 +> - 打招呼时会挥手 +> - 高兴时会摇摆或举手 +> - 思考时会弯曲身体 +> - 兴奋时会做太空步 +> - 告别时会挥手 + +## 功能概述 + +otto 机器人具有丰富的动作能力,包括行走、转向、跳跃、摇摆等多种舞蹈动作。 + +### 动作参数建议 +- **低速动作**:speed = 1200-1500 (适合精确控制) +- **中速动作**:speed = 900-1200 (日常使用推荐) +- **高速动作**:speed = 500-800 (表演和娱乐) +- **小幅度**:amount = 10-30 (细腻动作) +- **中幅度**:amount = 30-60 (标准动作) +- **大幅度**:amount = 60-120 (夸张表演) + +### 动作 + +| MCP工具名称 | 描述 | 参数说明 | +|-------------------|-----------------|---------------------------------------------------| +| self.otto.walk_forward | 行走 | **steps**: 行走步数(1-100,默认3)
**speed**: 行走速度(500-1500,数值越小越快,默认1000)
**direction**: 行走方向(-1=后退, 1=前进,默认1)
**arm_swing**: 手臂摆动幅度(0-170度,默认50) | +| self.otto.turn_left | 转身 | **steps**: 转身步数(1-100,默认3)
**speed**: 转身速度(500-1500,数值越小越快,默认1000)
**direction**: 转身方向(1=左转, -1=右转,默认1)
**arm_swing**: 手臂摆动幅度(0-170度,默认50) | +| self.otto.jump | 跳跃 | **steps**: 跳跃次数(1-100,默认1)
**speed**: 跳跃速度(500-1500,数值越小越快,默认1000) | +| self.otto.swing | 左右摇摆 | **steps**: 摇摆次数(1-100,默认3)
**speed**: 摇摆速度(500-1500,数值越小越快,默认1000)
**amount**: 摇摆幅度(0-170度,默认30) | +| self.otto.moonwalk | 太空步 | **steps**: 太空步步数(1-100,默认3)
**speed**: 速度(500-1500,数值越小越快,默认1000)
**direction**: 方向(1=左, -1=右,默认1)
**amount**: 幅度(0-170度,默认25) | +| self.otto.bend | 弯曲身体 | **steps**: 弯曲次数(1-100,默认1)
**speed**: 弯曲速度(500-1500,数值越小越快,默认1000)
**direction**: 弯曲方向(1=左, -1=右,默认1) | +| self.otto.shake_leg | 摇腿 | **steps**: 摇腿次数(1-100,默认1)
**speed**: 摇腿速度(500-1500,数值越小越快,默认1000)
**direction**: 腿部选择(1=左腿, -1=右腿,默认1) | +| self.otto.updown | 上下运动 | **steps**: 上下运动次数(1-100,默认3)
**speed**: 运动速度(500-1500,数值越小越快,默认1000)
**amount**: 运动幅度(0-170度,默认20) | +| self.otto.hands_up | 举手 * | **speed**: 举手速度(500-1500,数值越小越快,默认1000)
**direction**: 手部选择(1=左手, -1=右手, 0=双手,默认1) | +| self.otto.hands_down | 放手 * | **speed**: 放手速度(500-1500,数值越小越快,默认1000)
**direction**: 手部选择(1=左手, -1=右手, 0=双手,默认1) | +| self.otto.hand_wave | 挥手 * | **speed**: 挥手速度(500-1500,数值越小越快,默认1000)
**direction**: 手部选择(1=左手, -1=右手, 0=双手,默认1) | + +**注**: 标记 * 的手部动作仅在配置了手部舵机时可用。 + +### 系统工具 + +| MCP工具名称 | 描述 | 返回值 | +|-------------------|-----------------|---------------------------------------------------| +| self.otto.stop | 立即停止 | 停止当前动作并回到初始位置 | +| self.otto.get_status | 获取机器人状态 | 返回 "moving" 或 "idle" | +| self.battery.get_level | 获取电池状态 | 返回电量百分比和充电状态的JSON格式 | + +### 参数说明 + +1. **steps**: 动作执行的步数/次数,数值越大动作持续时间越长 +2. **speed**: 动作执行速度,数值范围500-1500,**数值越小越快** +3. **direction**: 方向参数 + - 移动动作: 1=左/前进, -1=右/后退 + - 手部动作: 1=左手, -1=右手, 0=双手 +4. **amount/arm_swing**: 动作幅度,范围0-170度 + - 0表示不摆动(适用于手臂摆动) + - 数值越大幅度越大 + +### 动作控制 +- 每个动作执行完成后,机器人会自动回到初始位置(home),以便于执行下一个动作 +- 所有参数都有合理的默认值,可以省略不需要自定义的参数 +- 动作在后台任务中执行,不会阻塞主程序 +- 支持动作队列,可以连续执行多个动作 + +### MCP工具调用示例 +```json +// 向前走3步 +{"name": "self.otto.walk_forward", "arguments": {}} + +// 向前走5步,稍快一些 +{"name": "self.otto.walk_forward", "arguments": {"steps": 5, "speed": 800}} + +// 左转2步,大幅度摆动手臂 +{"name": "self.otto.turn_left", "arguments": {"steps": 2, "arm_swing": 100}} + +// 摇摆舞蹈,中等幅度 +{"name": "self.otto.swing", "arguments": {"steps": 5, "amount": 50}} + +// 挥左手打招呼 +{"name": "self.otto.hand_wave", "arguments": {"direction": 1}} + +// 立即停止 +{"name": "self.otto.stop", "arguments": {}} +``` + +### 语音指令示例 +- "向前走" / "向前走5步" / "快速向前" +- "左转" / "右转" / "转身" +- "跳跃" / "跳一下" +- "摇摆" / "跳舞" +- "太空步" / "月球漫步" +- "挥手" / "举手" / "放手" +- "停止" / "停下" + +**说明**: 小智控制机器人动作是创建新的任务在后台控制,动作执行期间仍可接受新的语音指令。可以通过"停止"语音指令立即停下Otto。 + diff --git a/main/boards/otto-robot/config.h b/main/boards/otto-robot/config.h new file mode 100644 index 00000000..6f910638 --- /dev/null +++ b/main/boards/otto-robot/config.h @@ -0,0 +1,52 @@ +#ifndef _BOARD_CONFIG_H_ +#define _BOARD_CONFIG_H_ + +#include + +#define POWER_CHARGE_DETECT_PIN GPIO_NUM_21 +#define POWER_ADC_UNIT ADC_UNIT_2 +#define POWER_ADC_CHANNEL ADC_CHANNEL_3 + +#define RIGHT_LEG_PIN GPIO_NUM_39 +#define RIGHT_FOOT_PIN GPIO_NUM_38 +#define LEFT_LEG_PIN GPIO_NUM_17 +#define LEFT_FOOT_PIN GPIO_NUM_18 +#define LEFT_HAND_PIN GPIO_NUM_8 +#define RIGHT_HAND_PIN GPIO_NUM_12 + +#define AUDIO_INPUT_SAMPLE_RATE 16000 +#define AUDIO_OUTPUT_SAMPLE_RATE 24000 +#define AUDIO_I2S_METHOD_SIMPLEX + +#define AUDIO_I2S_MIC_GPIO_WS GPIO_NUM_4 +#define AUDIO_I2S_MIC_GPIO_SCK GPIO_NUM_5 +#define AUDIO_I2S_MIC_GPIO_DIN GPIO_NUM_6 +#define AUDIO_I2S_SPK_GPIO_DOUT GPIO_NUM_7 +#define AUDIO_I2S_SPK_GPIO_BCLK GPIO_NUM_15 +#define AUDIO_I2S_SPK_GPIO_LRCK GPIO_NUM_16 + +#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_3 +#define DISPLAY_MOSI_PIN GPIO_NUM_10 +#define DISPLAY_CLK_PIN GPIO_NUM_9 +#define DISPLAY_DC_PIN GPIO_NUM_46 +#define DISPLAY_RST_PIN GPIO_NUM_11 +#define DISPLAY_CS_PIN GPIO_NUM_12 + +#define LCD_TYPE_ST7789_SERIAL +#define DISPLAY_WIDTH 240 +#define DISPLAY_HEIGHT 240 +#define DISPLAY_MIRROR_X false +#define DISPLAY_MIRROR_Y false +#define DISPLAY_SWAP_XY false +#define DISPLAY_INVERT_COLOR true +#define DISPLAY_RGB_ORDER LCD_RGB_ELEMENT_ORDER_RGB +#define DISPLAY_OFFSET_X 0 +#define DISPLAY_OFFSET_Y 0 +#define DISPLAY_BACKLIGHT_OUTPUT_INVERT false +#define DISPLAY_SPI_MODE 3 + +#define BOOT_BUTTON_GPIO GPIO_NUM_0 + +#define OTTO_ROBOT_VERSION "1.4.2" + +#endif // _BOARD_CONFIG_H_ diff --git a/main/boards/otto-robot/config.json b/main/boards/otto-robot/config.json new file mode 100644 index 00000000..fa6b61a7 --- /dev/null +++ b/main/boards/otto-robot/config.json @@ -0,0 +1,10 @@ +{ + "target": "esp32s3", + "builds": [ + { + "name": "otto-robot", + "sdkconfig_append": [ + ] + } + ] +} \ No newline at end of file diff --git a/main/boards/otto-robot/oscillator.cc b/main/boards/otto-robot/oscillator.cc new file mode 100644 index 00000000..adca7ac1 --- /dev/null +++ b/main/boards/otto-robot/oscillator.cc @@ -0,0 +1,153 @@ +#include "oscillator.h" + +#include +#include + +#include +#include + +static const char* TAG = "Oscillator"; + +extern unsigned long IRAM_ATTR millis(); + +static ledc_channel_t next_free_channel = LEDC_CHANNEL_0; + +Oscillator::Oscillator(int trim) { + trim_ = trim; + diff_limit_ = 0; + is_attached_ = false; + + sampling_period_ = 30; + period_ = 2000; + number_samples_ = period_ / sampling_period_; + inc_ = 2 * M_PI / number_samples_; + + amplitude_ = 45; + phase_ = 0; + phase0_ = 0; + offset_ = 0; + stop_ = false; + rev_ = false; + + pos_ = 90; + previous_millis_ = 0; +} + +Oscillator::~Oscillator() { + Detach(); +} + +uint32_t Oscillator::AngleToCompare(int angle) { + return (angle - SERVO_MIN_DEGREE) * (SERVO_MAX_PULSEWIDTH_US - SERVO_MIN_PULSEWIDTH_US) / + (SERVO_MAX_DEGREE - SERVO_MIN_DEGREE) + + SERVO_MIN_PULSEWIDTH_US; +} + +bool Oscillator::NextSample() { + current_millis_ = millis(); + + if (current_millis_ - previous_millis_ > sampling_period_) { + previous_millis_ = current_millis_; + return true; + } + + return false; +} + +void Oscillator::Attach(int pin, bool rev) { + if (is_attached_) { + Detach(); + } + + pin_ = pin; + rev_ = rev; + + ledc_timer_config_t ledc_timer = {.speed_mode = LEDC_LOW_SPEED_MODE, + .duty_resolution = LEDC_TIMER_13_BIT, + .timer_num = LEDC_TIMER_1, + .freq_hz = 50, + .clk_cfg = LEDC_AUTO_CLK}; + ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer)); + + static int last_channel = 0; + last_channel = (last_channel + 1) % 7 + 1; + ledc_channel_ = (ledc_channel_t)last_channel; + + ledc_channel_config_t ledc_channel = {.gpio_num = pin_, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = ledc_channel_, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = LEDC_TIMER_1, + .duty = 0, + .hpoint = 0}; + ESP_ERROR_CHECK(ledc_channel_config(&ledc_channel)); + + ledc_speed_mode_ = LEDC_LOW_SPEED_MODE; + + // pos_ = 90; + // Write(pos_); + previous_servo_command_millis_ = millis(); + + is_attached_ = true; +} + +void Oscillator::Detach() { + if (!is_attached_) + return; + + ESP_ERROR_CHECK(ledc_stop(ledc_speed_mode_, ledc_channel_, 0)); + + is_attached_ = false; +} + +void Oscillator::SetT(unsigned int T) { + period_ = T; + + number_samples_ = period_ / sampling_period_; + inc_ = 2 * M_PI / number_samples_; +} + +void Oscillator::SetPosition(int position) { + Write(position); +} + +void Oscillator::Refresh() { + if (NextSample()) { + if (!stop_) { + int pos = std::round(amplitude_ * std::sin(phase_ + phase0_) + offset_); + if (rev_) + pos = -pos; + Write(pos + 90); + } + + phase_ = phase_ + inc_; + } +} + +void Oscillator::Write(int position) { + if (!is_attached_) + return; + + long currentMillis = millis(); + if (diff_limit_ > 0) { + int limit = std::max( + 1, (((int)(currentMillis - previous_servo_command_millis_)) * diff_limit_) / 1000); + if (abs(position - pos_) > limit) { + pos_ += position < pos_ ? -limit : limit; + } else { + pos_ = position; + } + } else { + pos_ = position; + } + previous_servo_command_millis_ = currentMillis; + + int angle = pos_ + trim_; + + angle = std::min(std::max(angle, 0), 180); + + uint32_t duty = (uint32_t)(((angle / 180.0) * 2.0 + 0.5) * 8191 / 20.0); + + ESP_ERROR_CHECK(ledc_set_duty(ledc_speed_mode_, ledc_channel_, duty)); + ESP_ERROR_CHECK(ledc_update_duty(ledc_speed_mode_, ledc_channel_)); +} diff --git a/main/boards/otto-robot/oscillator.h b/main/boards/otto-robot/oscillator.h new file mode 100644 index 00000000..d9e79f25 --- /dev/null +++ b/main/boards/otto-robot/oscillator.h @@ -0,0 +1,83 @@ +#ifndef __OSCILLATOR_H__ +#define __OSCILLATOR_H__ + +#include "driver/ledc.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define M_PI 3.14159265358979323846 + +#ifndef DEG2RAD +#define DEG2RAD(g) ((g) * M_PI) / 180 +#endif + +#define SERVO_MIN_PULSEWIDTH_US 500 // 最小脉宽(微秒) +#define SERVO_MAX_PULSEWIDTH_US 2500 // 最大脉宽(微秒) +#define SERVO_MIN_DEGREE -90 // 最小角度 +#define SERVO_MAX_DEGREE 90 // 最大角度 +#define SERVO_TIMEBASE_RESOLUTION_HZ 1000000 // 1MHz, 1us per tick +#define SERVO_TIMEBASE_PERIOD 20000 // 20000 ticks, 20ms + +class Oscillator { +public: + Oscillator(int trim = 0); + ~Oscillator(); + void Attach(int pin, bool rev = false); + void Detach(); + + void SetA(unsigned int amplitude) { amplitude_ = amplitude; }; + void SetO(int offset) { offset_ = offset; }; + void SetPh(double Ph) { phase0_ = Ph; }; + void SetT(unsigned int period); + void SetTrim(int trim) { trim_ = trim; }; + void SetLimiter(int diff_limit) { diff_limit_ = diff_limit; }; + void DisableLimiter() { diff_limit_ = 0; }; + int GetTrim() { return trim_; }; + void SetPosition(int position); + void Stop() { stop_ = true; }; + void Play() { stop_ = false; }; + void Reset() { phase_ = 0; }; + void Refresh(); + int GetPosition() { return pos_; } + +private: + bool NextSample(); + void Write(int position); + uint32_t AngleToCompare(int angle); + +private: + bool is_attached_; + + //-- Oscillators parameters + unsigned int amplitude_; //-- Amplitude (degrees) + int offset_; //-- Offset (degrees) + unsigned int period_; //-- Period (miliseconds) + double phase0_; //-- Phase (radians) + + //-- Internal variables + int pos_; //-- Current servo pos + int pin_; //-- Pin where the servo is connected + int trim_; //-- Calibration offset + double phase_; //-- Current phase + double inc_; //-- Increment of phase + double number_samples_; //-- Number of samples + unsigned int sampling_period_; //-- sampling period (ms) + + long previous_millis_; + long current_millis_; + + //-- Oscillation mode. If true, the servo is stopped + bool stop_; + + //-- Reverse mode + bool rev_; + + int diff_limit_; + long previous_servo_command_millis_; + + ledc_channel_t ledc_channel_; + ledc_mode_t ledc_speed_mode_; +}; + +#endif // __OSCILLATOR_H__ \ No newline at end of file diff --git a/main/boards/otto-robot/otto_controller.cc b/main/boards/otto-robot/otto_controller.cc new file mode 100644 index 00000000..4df66c55 --- /dev/null +++ b/main/boards/otto-robot/otto_controller.cc @@ -0,0 +1,383 @@ +/* + Otto机器人控制器 - MCP协议版本 +*/ + +#include +#include + +#include + +#include "application.h" +#include "board.h" +#include "config.h" +#include "mcp_server.h" +#include "otto_movements.h" +#include "sdkconfig.h" + +#define TAG "OttoController" + +class OttoController { +private: + Otto otto_; + TaskHandle_t action_task_handle_ = nullptr; + QueueHandle_t action_queue_; + bool has_hands_ = false; + bool is_action_in_progress_ = false; + + struct OttoActionParams { + int action_type; + int steps; + int speed; + int direction; + int amount; + }; + + enum ActionType { + ACTION_WALK = 1, + ACTION_TURN = 2, + ACTION_JUMP = 3, + ACTION_SWING = 4, + ACTION_MOONWALK = 5, + ACTION_BEND = 6, + ACTION_SHAKE_LEG = 7, + ACTION_UPDOWN = 8, + ACTION_TIPTOE_SWING = 9, + ACTION_JITTER = 10, + ACTION_ASCENDING_TURN = 11, + ACTION_CRUSAITO = 12, + ACTION_FLAPPING = 13, + ACTION_HANDS_UP = 14, + ACTION_HANDS_DOWN = 15, + ACTION_HAND_WAVE = 16 + }; + + static void ActionTask(void* arg) { + OttoController* controller = static_cast(arg); + OttoActionParams params; + controller->otto_.AttachServos(); + + while (true) { + if (xQueueReceive(controller->action_queue_, ¶ms, pdMS_TO_TICKS(1000)) == pdTRUE) { + ESP_LOGI(TAG, "执行动作: %d", params.action_type); + controller->is_action_in_progress_ = true; + + switch (params.action_type) { + case ACTION_WALK: + controller->otto_.Walk(params.steps, params.speed, params.direction, + params.amount); + break; + case ACTION_TURN: + controller->otto_.Turn(params.steps, params.speed, params.direction, + params.amount); + break; + case ACTION_JUMP: + controller->otto_.Jump(params.steps, params.speed); + break; + case ACTION_SWING: + controller->otto_.Swing(params.steps, params.speed, params.amount); + break; + case ACTION_MOONWALK: + controller->otto_.Moonwalker(params.steps, params.speed, params.amount, + params.direction); + break; + case ACTION_BEND: + controller->otto_.Bend(params.steps, params.speed, params.direction); + break; + case ACTION_SHAKE_LEG: + controller->otto_.ShakeLeg(params.steps, params.speed, params.direction); + break; + case ACTION_UPDOWN: + controller->otto_.UpDown(params.steps, params.speed, params.amount); + break; + case ACTION_TIPTOE_SWING: + controller->otto_.TiptoeSwing(params.steps, params.speed, params.amount); + break; + case ACTION_JITTER: + controller->otto_.Jitter(params.steps, params.speed, params.amount); + break; + case ACTION_ASCENDING_TURN: + controller->otto_.AscendingTurn(params.steps, params.speed, params.amount); + break; + case ACTION_CRUSAITO: + controller->otto_.Crusaito(params.steps, params.speed, params.amount, + params.direction); + break; + case ACTION_FLAPPING: + controller->otto_.Flapping(params.steps, params.speed, params.amount, + params.direction); + break; + case ACTION_HANDS_UP: + if (controller->has_hands_) { + controller->otto_.HandsUp(params.speed, params.direction); + } + break; + case ACTION_HANDS_DOWN: + if (controller->has_hands_) { + controller->otto_.HandsDown(params.speed, params.direction); + } + break; + case ACTION_HAND_WAVE: + if (controller->has_hands_) { + controller->otto_.HandWave(params.speed, params.direction); + } + break; + } + controller->otto_.Home(params.action_type < ACTION_HANDS_UP); + controller->is_action_in_progress_ = false; + } + vTaskDelay(pdMS_TO_TICKS(20)); + } + } + + void StartActionTaskIfNeeded() { + if (action_task_handle_ == nullptr) { + xTaskCreate(ActionTask, "otto_action", 1024 * 3, this, configMAX_PRIORITIES - 1, + &action_task_handle_); + } + } + + void QueueAction(int action_type, int steps, int speed, int direction, int amount) { + // 检查手部动作 + if ((action_type >= ACTION_HANDS_UP && action_type <= ACTION_HAND_WAVE) && !has_hands_) { + ESP_LOGW(TAG, "尝试执行手部动作,但机器人没有配置手部舵机"); + return; + } + + ESP_LOGI(TAG, "动作控制: 类型=%d, 步数=%d, 速度=%d, 方向=%d, 幅度=%d", action_type, steps, + speed, direction, amount); + + OttoActionParams params = {action_type, steps, speed, direction, amount}; + xQueueSend(action_queue_, ¶ms, portMAX_DELAY); + StartActionTaskIfNeeded(); + } + +public: + OttoController() { + otto_.Init(LEFT_LEG_PIN, RIGHT_LEG_PIN, LEFT_FOOT_PIN, RIGHT_FOOT_PIN, LEFT_HAND_PIN, + RIGHT_HAND_PIN); + + has_hands_ = (LEFT_HAND_PIN != -1 && RIGHT_HAND_PIN != -1); + ESP_LOGI(TAG, "Otto机器人初始化%s手部舵机", has_hands_ ? "带" : "不带"); + + otto_.Home(true); + action_queue_ = xQueueCreate(10, sizeof(OttoActionParams)); + + RegisterMcpTools(); + } + + void RegisterMcpTools() { + auto& mcp_server = McpServer::GetInstance(); + + ESP_LOGI(TAG, "开始注册MCP工具..."); + + // 基础移动动作 + mcp_server.AddTool("self.otto.walk_forward", + "行走。steps: 行走步数(1-100); speed: 行走速度(500-1500,数值越小越快); " + "direction: 行走方向(-1=后退, 1=前进); arm_swing: 手臂摆动幅度(0-170度)", + PropertyList({Property("steps", kPropertyTypeInteger, 3, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("arm_swing", kPropertyTypeInteger, 50, 0, 170), + Property("direction", kPropertyTypeInteger, 1, -1, 1)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int arm_swing = properties["arm_swing"].value(); + int direction = properties["direction"].value(); + QueueAction(ACTION_WALK, steps, speed, direction, arm_swing); + return true; + }); + + mcp_server.AddTool("self.otto.turn_left", + "转身。steps: 转身步数(1-100); speed: 转身速度(500-1500,数值越小越快); " + "direction: 转身方向(1=左转, -1=右转); arm_swing: 手臂摆动幅度(0-170度)", + PropertyList({Property("steps", kPropertyTypeInteger, 3, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("arm_swing", kPropertyTypeInteger, 50, 0, 170), + Property("direction", kPropertyTypeInteger, 1, -1, 1)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int arm_swing = properties["arm_swing"].value(); + int direction = properties["direction"].value(); + QueueAction(ACTION_TURN, steps, speed, direction, arm_swing); + return true; + }); + + mcp_server.AddTool("self.otto.jump", + "跳跃。steps: 跳跃次数(1-100); speed: 跳跃速度(500-1500,数值越小越快)", + PropertyList({Property("steps", kPropertyTypeInteger, 1, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + QueueAction(ACTION_JUMP, steps, speed, 0, 0); + return true; + }); + + // 特殊动作 + mcp_server.AddTool("self.otto.swing", + "左右摇摆。steps: 摇摆次数(1-100); speed: " + "摇摆速度(500-1500,数值越小越快); amount: 摇摆幅度(0-170度)", + PropertyList({Property("steps", kPropertyTypeInteger, 3, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("amount", kPropertyTypeInteger, 30, 0, 170)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int amount = properties["amount"].value(); + QueueAction(ACTION_SWING, steps, speed, 0, amount); + return true; + }); + + mcp_server.AddTool("self.otto.moonwalk", + "太空步。steps: 太空步步数(1-100); speed: 速度(500-1500,数值越小越快); " + "direction: 方向(1=左, -1=右); amount: 幅度(0-170度)", + PropertyList({Property("steps", kPropertyTypeInteger, 3, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("direction", kPropertyTypeInteger, 1, -1, 1), + Property("amount", kPropertyTypeInteger, 25, 0, 170)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int direction = properties["direction"].value(); + int amount = properties["amount"].value(); + QueueAction(ACTION_MOONWALK, steps, speed, direction, amount); + return true; + }); + + mcp_server.AddTool("self.otto.bend", + "弯曲身体。steps: 弯曲次数(1-100); speed: " + "弯曲速度(500-1500,数值越小越快); direction: 弯曲方向(1=左, -1=右)", + PropertyList({Property("steps", kPropertyTypeInteger, 1, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("direction", kPropertyTypeInteger, 1, -1, 1)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int direction = properties["direction"].value(); + QueueAction(ACTION_BEND, steps, speed, direction, 0); + return true; + }); + + mcp_server.AddTool("self.otto.shake_leg", + "摇腿。steps: 摇腿次数(1-100); speed: 摇腿速度(500-1500,数值越小越快); " + "direction: 腿部选择(1=左腿, -1=右腿)", + PropertyList({Property("steps", kPropertyTypeInteger, 1, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("direction", kPropertyTypeInteger, 1, -1, 1)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int direction = properties["direction"].value(); + QueueAction(ACTION_SHAKE_LEG, steps, speed, direction, 0); + return true; + }); + + mcp_server.AddTool("self.otto.updown", + "上下运动。steps: 上下运动次数(1-100); speed: " + "运动速度(500-1500,数值越小越快); amount: 运动幅度(0-170度)", + PropertyList({Property("steps", kPropertyTypeInteger, 3, 1, 100), + Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("amount", kPropertyTypeInteger, 20, 0, 170)}), + [this](const PropertyList& properties) -> ReturnValue { + int steps = properties["steps"].value(); + int speed = properties["speed"].value(); + int amount = properties["amount"].value(); + QueueAction(ACTION_UPDOWN, steps, speed, 0, amount); + return true; + }); + + // 手部动作(仅在有手部舵机时可用) + if (has_hands_) { + mcp_server.AddTool( + "self.otto.hands_up", + "举手。speed: 举手速度(500-1500,数值越小越快); direction: 手部选择(1=左手, " + "-1=右手, 0=双手)", + PropertyList({Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("direction", kPropertyTypeInteger, 1, -1, 1)}), + [this](const PropertyList& properties) -> ReturnValue { + int speed = properties["speed"].value(); + int direction = properties["direction"].value(); + QueueAction(ACTION_HANDS_UP, 1, speed, direction, 0); + return true; + }); + + mcp_server.AddTool( + "self.otto.hands_down", + "放手。speed: 放手速度(500-1500,数值越小越快); direction: 手部选择(1=左手, " + "-1=右手, 0=双手)", + PropertyList({Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("direction", kPropertyTypeInteger, 1, -1, 1)}), + [this](const PropertyList& properties) -> ReturnValue { + int speed = properties["speed"].value(); + int direction = properties["direction"].value(); + QueueAction(ACTION_HANDS_DOWN, 1, speed, direction, 0); + return true; + }); + + mcp_server.AddTool( + "self.otto.hand_wave", + "挥手。speed: 挥手速度(500-1500,数值越小越快); direction: 手部选择(1=左手, " + "-1=右手, 0=双手)", + PropertyList({Property("speed", kPropertyTypeInteger, 1000, 500, 1500), + Property("direction", kPropertyTypeInteger, 1, -1, 1)}), + [this](const PropertyList& properties) -> ReturnValue { + int speed = properties["speed"].value(); + int direction = properties["direction"].value(); + QueueAction(ACTION_HAND_WAVE, 1, speed, direction, 0); + return true; + }); + } + + // 系统工具 + mcp_server.AddTool("self.otto.stop", "立即停止", PropertyList(), + [this](const PropertyList& properties) -> ReturnValue { + if (action_task_handle_ != nullptr) { + vTaskDelete(action_task_handle_); + action_task_handle_ = nullptr; + } + is_action_in_progress_ = false; + xQueueReset(action_queue_); + otto_.Home(true); + return true; + }); + + mcp_server.AddTool("self.otto.get_status", "获取机器人状态,返回 moving 或 idle", + PropertyList(), [this](const PropertyList& properties) -> ReturnValue { + return is_action_in_progress_ ? "moving" : "idle"; + }); + + mcp_server.AddTool("self.battery.get_level", "获取机器人电池电量和充电状态", PropertyList(), + [](const PropertyList& properties) -> ReturnValue { + auto& board = Board::GetInstance(); + int level = 0; + bool charging = false; + bool discharging = false; + board.GetBatteryLevel(level, charging, discharging); + + std::string status = + "{\"level\":" + std::to_string(level) + + ",\"charging\":" + (charging ? "true" : "false") + "}"; + return status; + }); + + ESP_LOGI(TAG, "MCP工具注册完成"); + } + + ~OttoController() { + if (action_task_handle_ != nullptr) { + vTaskDelete(action_task_handle_); + action_task_handle_ = nullptr; + } + vQueueDelete(action_queue_); + } +}; + +static OttoController* g_otto_controller = nullptr; + +void InitializeOttoController() { + if (g_otto_controller == nullptr) { + g_otto_controller = new OttoController(); + ESP_LOGI(TAG, "Otto控制器已初始化并注册MCP工具"); + } +} diff --git a/main/boards/otto-robot/otto_emoji_display.cc b/main/boards/otto-robot/otto_emoji_display.cc new file mode 100644 index 00000000..7f7eb8c3 --- /dev/null +++ b/main/boards/otto-robot/otto_emoji_display.cc @@ -0,0 +1,145 @@ +#include "otto_emoji_display.h" + +#include + +#include +#include + +#include "display/lcd_display.h" +#define TAG "OttoEmojiDisplay" + +// 表情映射表 - 将原版21种表情映射到现有6个GIF +const OttoEmojiDisplay::EmotionMap OttoEmojiDisplay::emotion_maps_[] = { + // 中性/平静类表情 -> staticstate + {"neutral", &staticstate}, + {"relaxed", &staticstate}, + {"sleepy", &staticstate}, + + // 积极/开心类表情 -> happy + {"happy", &happy}, + {"laughing", &happy}, + {"funny", &happy}, + {"loving", &happy}, + {"confident", &happy}, + {"winking", &happy}, + {"cool", &happy}, + {"delicious", &happy}, + {"kissy", &happy}, + {"silly", &happy}, + + // 悲伤类表情 -> sad + {"sad", &sad}, + {"crying", &sad}, + + // 愤怒类表情 -> anger + {"angry", &anger}, + + // 惊讶类表情 -> scare + {"surprised", &scare}, + {"shocked", &scare}, + + // 思考/困惑类表情 -> buxue + {"thinking", &buxue}, + {"confused", &buxue}, + {"embarrassed", &buxue}, + + {nullptr, nullptr} // 结束标记 +}; + +OttoEmojiDisplay::OttoEmojiDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, bool mirror_x, + bool mirror_y, bool swap_xy, DisplayFonts fonts) + : SpiLcdDisplay(panel_io, panel, width, height, offset_x, offset_y, mirror_x, mirror_y, swap_xy, + fonts), + emotion_gif_(nullptr) { + SetupGifContainer(); +}; + +void OttoEmojiDisplay::SetupGifContainer() { + DisplayLockGuard lock(this); + + if (emotion_label_) { + lv_obj_del(emotion_label_); + } + + if (chat_message_label_) { + lv_obj_del(chat_message_label_); + } + if (content_) { + lv_obj_del(content_); + } + + content_ = lv_obj_create(container_); + lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_size(content_, LV_HOR_RES, LV_HOR_RES); + lv_obj_set_style_bg_opa(content_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(content_, 0, 0); + lv_obj_set_flex_grow(content_, 1); + lv_obj_center(content_); + + emotion_label_ = lv_label_create(content_); + lv_label_set_text(emotion_label_, ""); + lv_obj_set_width(emotion_label_, 0); + lv_obj_set_style_border_width(emotion_label_, 0, 0); + lv_obj_add_flag(emotion_label_, LV_OBJ_FLAG_HIDDEN); + + emotion_gif_ = lv_gif_create(content_); + int gif_size = LV_HOR_RES; + lv_obj_set_size(emotion_gif_, gif_size, gif_size); + lv_obj_set_style_border_width(emotion_gif_, 0, 0); + lv_obj_set_style_bg_opa(emotion_gif_, LV_OPA_TRANSP, 0); + lv_obj_center(emotion_gif_); + lv_gif_set_src(emotion_gif_, &staticstate); + + chat_message_label_ = lv_label_create(content_); + lv_label_set_text(chat_message_label_, ""); + lv_obj_set_width(chat_message_label_, LV_HOR_RES * 0.9); + lv_label_set_long_mode(chat_message_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(chat_message_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(chat_message_label_, lv_color_white(), 0); + lv_obj_set_style_border_width(chat_message_label_, 0, 0); + + lv_obj_set_style_bg_opa(chat_message_label_, LV_OPA_70, 0); + lv_obj_set_style_bg_color(chat_message_label_, lv_color_black(), 0); + lv_obj_set_style_pad_ver(chat_message_label_, 5, 0); + + lv_obj_align(chat_message_label_, LV_ALIGN_BOTTOM_MID, 0, 0); + + LcdDisplay::SetTheme("dark"); +} + +void OttoEmojiDisplay::SetEmotion(const char* emotion) { + if (!emotion || !emotion_gif_) { + return; + } + + DisplayLockGuard lock(this); + + for (const auto& map : emotion_maps_) { + if (map.name && strcmp(map.name, emotion) == 0) { + lv_gif_set_src(emotion_gif_, map.gif); + ESP_LOGI(TAG, "设置表情: %s", emotion); + return; + } + } + + lv_gif_set_src(emotion_gif_, &staticstate); + ESP_LOGI(TAG, "未知表情'%s',使用默认", emotion); +} + +void OttoEmojiDisplay::SetChatMessage(const char* role, const char* content) { + DisplayLockGuard lock(this); + if (chat_message_label_ == nullptr) { + return; + } + + if (content == nullptr || strlen(content) == 0) { + lv_obj_add_flag(chat_message_label_, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_label_set_text(chat_message_label_, content); + lv_obj_clear_flag(chat_message_label_, LV_OBJ_FLAG_HIDDEN); + + ESP_LOGI(TAG, "设置聊天消息 [%s]: %s", role, content); +} diff --git a/main/boards/otto-robot/otto_emoji_display.h b/main/boards/otto-robot/otto_emoji_display.h new file mode 100644 index 00000000..5b137019 --- /dev/null +++ b/main/boards/otto-robot/otto_emoji_display.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include "display/lcd_display.h" +#include "otto_emoji_gif.h" + +/** + * @brief Otto机器人GIF表情显示类 + * 继承LcdDisplay,添加GIF表情支持 + */ +class OttoEmojiDisplay : public SpiLcdDisplay { +public: + /** + * @brief 构造函数,参数与SpiLcdDisplay相同 + */ + OttoEmojiDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, int width, + int height, int offset_x, int offset_y, bool mirror_x, bool mirror_y, + bool swap_xy, DisplayFonts fonts); + + virtual ~OttoEmojiDisplay() = default; + + // 重写表情设置方法 + virtual void SetEmotion(const char* emotion) override; + + // 重写聊天消息设置方法 + virtual void SetChatMessage(const char* role, const char* content) override; + +private: + void SetupGifContainer(); + + lv_obj_t* emotion_gif_; ///< GIF表情组件 + + // 表情映射 + struct EmotionMap { + const char* name; + const lv_img_dsc_t* gif; + }; + + static const EmotionMap emotion_maps_[]; +}; \ No newline at end of file diff --git a/main/boards/otto-robot/otto_movements.cc b/main/boards/otto-robot/otto_movements.cc new file mode 100644 index 00000000..5f3315d3 --- /dev/null +++ b/main/boards/otto-robot/otto_movements.cc @@ -0,0 +1,763 @@ +#include "otto_movements.h" + +#include + +#include "oscillator.h" + +static const char* TAG = "OttoMovements"; + +#define HAND_HOME_POSITION 45 + +Otto::Otto() { + is_otto_resting_ = false; + has_hands_ = false; + // 初始化所有舵机管脚为-1(未连接) + for (int i = 0; i < SERVO_COUNT; i++) { + servo_pins_[i] = -1; + servo_trim_[i] = 0; + } +} + +Otto::~Otto() { + DetachServos(); +} + +unsigned long IRAM_ATTR millis() { + return (unsigned long)(esp_timer_get_time() / 1000ULL); +} + +void Otto::Init(int left_leg, int right_leg, int left_foot, int right_foot, int left_hand, + int right_hand) { + servo_pins_[LEFT_LEG] = left_leg; + servo_pins_[RIGHT_LEG] = right_leg; + servo_pins_[LEFT_FOOT] = left_foot; + servo_pins_[RIGHT_FOOT] = right_foot; + servo_pins_[LEFT_HAND] = left_hand; + servo_pins_[RIGHT_HAND] = right_hand; + + // 检查是否有手部舵机 + has_hands_ = (left_hand != -1 && right_hand != -1); + + AttachServos(); + is_otto_resting_ = false; +} + +/////////////////////////////////////////////////////////////////// +//-- ATTACH & DETACH FUNCTIONS ----------------------------------// +/////////////////////////////////////////////////////////////////// +void Otto::AttachServos() { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].Attach(servo_pins_[i]); + } + } +} + +void Otto::DetachServos() { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].Detach(); + } + } +} + +/////////////////////////////////////////////////////////////////// +//-- OSCILLATORS TRIMS ------------------------------------------// +/////////////////////////////////////////////////////////////////// +void Otto::SetTrims(int left_leg, int right_leg, int left_foot, int right_foot, int left_hand, + int right_hand) { + servo_trim_[LEFT_LEG] = left_leg; + servo_trim_[RIGHT_LEG] = right_leg; + servo_trim_[LEFT_FOOT] = left_foot; + servo_trim_[RIGHT_FOOT] = right_foot; + + if (has_hands_) { + servo_trim_[LEFT_HAND] = left_hand; + servo_trim_[RIGHT_HAND] = right_hand; + } + + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetTrim(servo_trim_[i]); + } + } +} + +/////////////////////////////////////////////////////////////////// +//-- BASIC MOTION FUNCTIONS -------------------------------------// +/////////////////////////////////////////////////////////////////// +void Otto::MoveServos(int time, int servo_target[]) { + if (GetRestState() == true) { + SetRestState(false); + } + + final_time_ = millis() + time; + if (time > 10) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + increment_[i] = (servo_target[i] - servo_[i].GetPosition()) / (time / 10.0); + } + } + + for (int iteration = 1; millis() < final_time_; iteration++) { + partial_time_ = millis() + 10; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetPosition(servo_[i].GetPosition() + increment_[i]); + } + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + } else { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetPosition(servo_target[i]); + } + } + vTaskDelay(pdMS_TO_TICKS(time)); + } + + // final adjustment to the target. + bool f = true; + int adjustment_count = 0; + while (f && adjustment_count < 10) { + f = false; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1 && servo_target[i] != servo_[i].GetPosition()) { + f = true; + break; + } + } + if (f) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetPosition(servo_target[i]); + } + } + vTaskDelay(pdMS_TO_TICKS(10)); + adjustment_count++; + } + }; +} + +void Otto::MoveSingle(int position, int servo_number) { + if (position > 180) + position = 90; + if (position < 0) + position = 90; + + if (GetRestState() == true) { + SetRestState(false); + } + + if (servo_number >= 0 && servo_number < SERVO_COUNT && servo_pins_[servo_number] != -1) { + servo_[servo_number].SetPosition(position); + } +} + +void Otto::OscillateServos(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float cycle = 1) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetO(offset[i]); + servo_[i].SetA(amplitude[i]); + servo_[i].SetT(period); + servo_[i].SetPh(phase_diff[i]); + } + } + + double ref = millis(); + double end_time = period * cycle + ref; + + while (millis() < end_time) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].Refresh(); + } + } + vTaskDelay(5); + } + vTaskDelay(pdMS_TO_TICKS(10)); +} + +void Otto::Execute(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float steps = 1.0) { + if (GetRestState() == true) { + SetRestState(false); + } + + int cycles = (int)steps; + + //-- Execute complete cycles + if (cycles >= 1) + for (int i = 0; i < cycles; i++) + OscillateServos(amplitude, offset, period, phase_diff); + + //-- Execute the final not complete cycle + OscillateServos(amplitude, offset, period, phase_diff, (float)steps - cycles); + vTaskDelay(pdMS_TO_TICKS(10)); +} + +/////////////////////////////////////////////////////////////////// +//-- HOME = Otto at rest position -------------------------------// +/////////////////////////////////////////////////////////////////// +void Otto::Home(bool hands_down) { + if (is_otto_resting_ == false) { // Go to rest position only if necessary + // 为所有舵机准备初始位置值 + int homes[SERVO_COUNT]; + for (int i = 0; i < SERVO_COUNT; i++) { + if (i == LEFT_HAND || i == RIGHT_HAND) { + if (hands_down) { + // 如果需要复位手部,设置为默认值 + if (i == LEFT_HAND) { + homes[i] = HAND_HOME_POSITION; + } else { // RIGHT_HAND + homes[i] = 180 - HAND_HOME_POSITION; // 右手镜像位置 + } + } else { + // 如果不需要复位手部,保持当前位置 + homes[i] = servo_[i].GetPosition(); + } + } else { + // 腿部和脚部舵机始终复位 + homes[i] = 90; + } + } + + MoveServos(500, homes); + is_otto_resting_ = true; + } + + vTaskDelay(pdMS_TO_TICKS(200)); +} + +bool Otto::GetRestState() { + return is_otto_resting_; +} + +void Otto::SetRestState(bool state) { + is_otto_resting_ = state; +} + +/////////////////////////////////////////////////////////////////// +//-- PREDETERMINED MOTION SEQUENCES -----------------------------// +/////////////////////////////////////////////////////////////////// +//-- Otto movement: Jump +//-- Parameters: +//-- steps: Number of steps +//-- T: Period +//--------------------------------------------------------- +void Otto::Jump(float steps, int period) { + int up[SERVO_COUNT] = {90, 90, 150, 30, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + MoveServos(period, up); + int down[SERVO_COUNT] = {90, 90, 90, 90, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + MoveServos(period, down); +} + +//--------------------------------------------------------- +//-- Otto gait: Walking (forward or backward) +//-- Parameters: +//-- * steps: Number of steps +//-- * T : Period +//-- * Dir: Direction: FORWARD / BACKWARD +//-- * amount: 手部摆动幅度, 0表示不摆动 +//--------------------------------------------------------- +void Otto::Walk(float steps, int period, int dir, int amount) { + //-- Oscillator parameters for walking + //-- Hip sevos are in phase + //-- Feet servos are in phase + //-- Hip and feet are 90 degrees out of phase + //-- -90 : Walk forward + //-- 90 : Walk backward + //-- Feet servos also have the same offset (for tiptoe a little bit) + int A[SERVO_COUNT] = {30, 30, 30, 30, 0, 0}; + int O[SERVO_COUNT] = {0, 0, 5, -5, HAND_HOME_POSITION - 90, HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {0, 0, DEG2RAD(dir * -90), DEG2RAD(dir * -90), 0, 0}; + + // 如果amount>0且有手部舵机,设置手部振幅和相位 + if (amount > 0 && has_hands_) { + // 手臂振幅使用传入的amount参数 + A[LEFT_HAND] = amount; + A[RIGHT_HAND] = amount; + + // 左手与右腿同相,右手与左腿同相,使得机器人走路时手臂自然摆动 + phase_diff[LEFT_HAND] = phase_diff[RIGHT_LEG]; // 左手与右腿同相 + phase_diff[RIGHT_HAND] = phase_diff[LEFT_LEG]; // 右手与左腿同相 + } else { + A[LEFT_HAND] = 0; + A[RIGHT_HAND] = 0; + } + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto gait: Turning (left or right) +//-- Parameters: +//-- * Steps: Number of steps +//-- * T: Period +//-- * Dir: Direction: LEFT / RIGHT +//-- * amount: 手部摆动幅度, 0表示不摆动 +//--------------------------------------------------------- +void Otto::Turn(float steps, int period, int dir, int amount) { + //-- Same coordination than for walking (see Otto::walk) + //-- The Amplitudes of the hip's oscillators are not igual + //-- When the right hip servo amplitude is higher, the steps taken by + //-- the right leg are bigger than the left. So, the robot describes an + //-- left arc + int A[SERVO_COUNT] = {30, 30, 30, 30, 0, 0}; + int O[SERVO_COUNT] = {0, 0, 5, -5, HAND_HOME_POSITION - 90, HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {0, 0, DEG2RAD(-90), DEG2RAD(-90), 0, 0}; + + if (dir == LEFT) { + A[0] = 30; //-- Left hip servo + A[1] = 0; //-- Right hip servo + } else { + A[0] = 0; + A[1] = 30; + } + + // 如果amount>0且有手部舵机,设置手部振幅和相位 + if (amount > 0 && has_hands_) { + // 手臂振幅使用传入的amount参数 + A[LEFT_HAND] = amount; + A[RIGHT_HAND] = amount; + + // 转向时手臂摆动相位:左手与左腿同相,右手与右腿同相,增强转向效果 + phase_diff[LEFT_HAND] = phase_diff[LEFT_LEG]; // 左手与左腿同相 + phase_diff[RIGHT_HAND] = phase_diff[RIGHT_LEG]; // 右手与右腿同相 + } else { + A[LEFT_HAND] = 0; + A[RIGHT_HAND] = 0; + } + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto gait: Lateral bend +//-- Parameters: +//-- steps: Number of bends +//-- T: Period of one bend +//-- dir: RIGHT=Right bend LEFT=Left bend +//--------------------------------------------------------- +void Otto::Bend(int steps, int period, int dir) { + // Parameters of all the movements. Default: Left bend + int bend1[SERVO_COUNT] = {90, 90, 62, 35, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + int bend2[SERVO_COUNT] = {90, 90, 62, 105, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + int homes[SERVO_COUNT] = {90, 90, 90, 90, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + + // Time of one bend, constrained in order to avoid movements too fast. + // T=max(T, 600); + // Changes in the parameters if right direction is chosen + if (dir == -1) { + bend1[2] = 180 - 35; + bend1[3] = 180 - 60; // Not 65. Otto is unbalanced + bend2[2] = 180 - 105; + bend2[3] = 180 - 60; + } + + // Time of the bend movement. Fixed parameter to avoid falls + int T2 = 800; + + // Bend movement + for (int i = 0; i < steps; i++) { + MoveServos(T2 / 2, bend1); + MoveServos(T2 / 2, bend2); + vTaskDelay(pdMS_TO_TICKS(period * 0.8)); + MoveServos(500, homes); + } +} + +//--------------------------------------------------------- +//-- Otto gait: Shake a leg +//-- Parameters: +//-- steps: Number of shakes +//-- T: Period of one shake +//-- dir: RIGHT=Right leg LEFT=Left leg +//--------------------------------------------------------- +void Otto::ShakeLeg(int steps, int period, int dir) { + // This variable change the amount of shakes + int numberLegMoves = 2; + + // Parameters of all the movements. Default: Right leg + int shake_leg1[SERVO_COUNT] = {90, 90, 58, 35, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + int shake_leg2[SERVO_COUNT] = {90, 90, 58, 120, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + int shake_leg3[SERVO_COUNT] = {90, 90, 58, 60, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + int homes[SERVO_COUNT] = {90, 90, 90, 90, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + + // Changes in the parameters if left leg is chosen + if (dir == -1) { + shake_leg1[2] = 180 - 35; + shake_leg1[3] = 180 - 58; + shake_leg2[2] = 180 - 120; + shake_leg2[3] = 180 - 58; + shake_leg3[2] = 180 - 60; + shake_leg3[3] = 180 - 58; + } + + // Time of the bend movement. Fixed parameter to avoid falls + int T2 = 1000; + // Time of one shake, constrained in order to avoid movements too fast. + period = period - T2; + period = std::max(period, 200 * numberLegMoves); + + for (int j = 0; j < steps; j++) { + // Bend movement + MoveServos(T2 / 2, shake_leg1); + MoveServos(T2 / 2, shake_leg2); + + // Shake movement + for (int i = 0; i < numberLegMoves; i++) { + MoveServos(period / (2 * numberLegMoves), shake_leg3); + MoveServos(period / (2 * numberLegMoves), shake_leg2); + } + MoveServos(500, homes); // Return to home position + } + + vTaskDelay(pdMS_TO_TICKS(period)); +} + +//--------------------------------------------------------- +//-- Otto movement: up & down +//-- Parameters: +//-- * steps: Number of jumps +//-- * T: Period +//-- * h: Jump height: SMALL / MEDIUM / BIG +//-- (or a number in degrees 0 - 90) +//--------------------------------------------------------- +void Otto::UpDown(float steps, int period, int height) { + //-- Both feet are 180 degrees out of phase + //-- Feet amplitude and offset are the same + //-- Initial phase for the right foot is -90, so that it starts + //-- in one extreme position (not in the middle) + int A[SERVO_COUNT] = {0, 0, height, height, 0, 0}; + int O[SERVO_COUNT] = {0, 0, height, -height, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {0, 0, DEG2RAD(-90), DEG2RAD(90), 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto movement: swinging side to side +//-- Parameters: +//-- steps: Number of steps +//-- T : Period +//-- h : Amount of swing (from 0 to 50 aprox) +//--------------------------------------------------------- +void Otto::Swing(float steps, int period, int height) { + //-- Both feets are in phase. The offset is half the amplitude + //-- It causes the robot to swing from side to side + int A[SERVO_COUNT] = {0, 0, height, height, 0, 0}; + int O[SERVO_COUNT] = { + 0, 0, height / 2, -height / 2, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {0, 0, DEG2RAD(0), DEG2RAD(0), 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto movement: swinging side to side without touching the floor with the heel +//-- Parameters: +//-- steps: Number of steps +//-- T : Period +//-- h : Amount of swing (from 0 to 50 aprox) +//--------------------------------------------------------- +void Otto::TiptoeSwing(float steps, int period, int height) { + //-- Both feets are in phase. The offset is not half the amplitude in order to tiptoe + //-- It causes the robot to swing from side to side + int A[SERVO_COUNT] = {0, 0, height, height, 0, 0}; + int O[SERVO_COUNT] = {0, 0, height, -height, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {0, 0, 0, 0, 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto gait: Jitter +//-- Parameters: +//-- steps: Number of jitters +//-- T: Period of one jitter +//-- h: height (Values between 5 - 25) +//--------------------------------------------------------- +void Otto::Jitter(float steps, int period, int height) { + //-- Both feet are 180 degrees out of phase + //-- Feet amplitude and offset are the same + //-- Initial phase for the right foot is -90, so that it starts + //-- in one extreme position (not in the middle) + //-- h is constrained to avoid hit the feets + height = std::min(25, height); + int A[SERVO_COUNT] = {height, height, 0, 0, 0, 0}; + int O[SERVO_COUNT] = {0, 0, 0, 0, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {DEG2RAD(-90), DEG2RAD(90), 0, 0, 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto gait: Ascending & turn (Jitter while up&down) +//-- Parameters: +//-- steps: Number of bends +//-- T: Period of one bend +//-- h: height (Values between 5 - 15) +//--------------------------------------------------------- +void Otto::AscendingTurn(float steps, int period, int height) { + //-- Both feet and legs are 180 degrees out of phase + //-- Initial phase for the right foot is -90, so that it starts + //-- in one extreme position (not in the middle) + //-- h is constrained to avoid hit the feets + height = std::min(13, height); + int A[SERVO_COUNT] = {height, height, height, height, 0, 0}; + int O[SERVO_COUNT] = { + 0, 0, height + 4, -height + 4, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {DEG2RAD(-90), DEG2RAD(90), DEG2RAD(-90), DEG2RAD(90), 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto gait: Moonwalker. Otto moves like Michael Jackson +//-- Parameters: +//-- Steps: Number of steps +//-- T: Period +//-- h: Height. Typical valures between 15 and 40 +//-- dir: Direction: LEFT / RIGHT +//--------------------------------------------------------- +void Otto::Moonwalker(float steps, int period, int height, int dir) { + //-- This motion is similar to that of the caterpillar robots: A travelling + //-- wave moving from one side to another + //-- The two Otto's feet are equivalent to a minimal configuration. It is known + //-- that 2 servos can move like a worm if they are 120 degrees out of phase + //-- In the example of Otto, the two feet are mirrored so that we have: + //-- 180 - 120 = 60 degrees. The actual phase difference given to the oscillators + //-- is 60 degrees. + //-- Both amplitudes are equal. The offset is half the amplitud plus a little bit of + //- offset so that the robot tiptoe lightly + + int A[SERVO_COUNT] = {0, 0, height, height, 0, 0}; + int O[SERVO_COUNT] = { + 0, 0, height / 2 + 2, -height / 2 - 2, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + int phi = -dir * 90; + double phase_diff[SERVO_COUNT] = {0, 0, DEG2RAD(phi), DEG2RAD(-60 * dir + phi), 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//---------------------------------------------------------- +//-- Otto gait: Crusaito. A mixture between moonwalker and walk +//-- Parameters: +//-- steps: Number of steps +//-- T: Period +//-- h: height (Values between 20 - 50) +//-- dir: Direction: LEFT / RIGHT +//----------------------------------------------------------- +void Otto::Crusaito(float steps, int period, int height, int dir) { + int A[SERVO_COUNT] = {25, 25, height, height, 0, 0}; + int O[SERVO_COUNT] = { + 0, 0, height / 2 + 4, -height / 2 - 4, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = {90, 90, DEG2RAD(0), DEG2RAD(-60 * dir), 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- Otto gait: Flapping +//-- Parameters: +//-- steps: Number of steps +//-- T: Period +//-- h: height (Values between 10 - 30) +//-- dir: direction: FOREWARD, BACKWARD +//--------------------------------------------------------- +void Otto::Flapping(float steps, int period, int height, int dir) { + int A[SERVO_COUNT] = {12, 12, height, height, 0, 0}; + int O[SERVO_COUNT] = { + 0, 0, height - 10, -height + 10, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + double phase_diff[SERVO_COUNT] = { + DEG2RAD(0), DEG2RAD(180), DEG2RAD(-90 * dir), DEG2RAD(90 * dir), 0, 0}; + + //-- Let's oscillate the servos! + Execute(A, O, period, phase_diff, steps); +} + +//--------------------------------------------------------- +//-- 手部动作: 举手 +//-- Parameters: +//-- period: 动作时间 +//-- dir: 方向 1=左手, -1=右手, 0=双手 +//--------------------------------------------------------- +void Otto::HandsUp(int period, int dir) { + if (!has_hands_) { + return; + } + + int initial[SERVO_COUNT] = {90, 90, 90, 90, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + int target[SERVO_COUNT] = {90, 90, 90, 90, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + + if (dir == 0) { + target[LEFT_HAND] = 170; + target[RIGHT_HAND] = 10; + } else if (dir == 1) { + target[LEFT_HAND] = 170; + target[RIGHT_HAND] = servo_[RIGHT_HAND].GetPosition(); + } else if (dir == -1) { + target[RIGHT_HAND] = 10; + target[LEFT_HAND] = servo_[LEFT_HAND].GetPosition(); + } + + MoveServos(period, target); +} + +//--------------------------------------------------------- +//-- 手部动作: 双手放下 +//-- Parameters: +//-- period: 动作时间 +//-- dir: 方向 1=左手, -1=右手, 0=双手 +//--------------------------------------------------------- +void Otto::HandsDown(int period, int dir) { + if (!has_hands_) { + return; + } + + int target[SERVO_COUNT] = {90, 90, 90, 90, HAND_HOME_POSITION, 180 - HAND_HOME_POSITION}; + + if (dir == 1) { + target[RIGHT_HAND] = servo_[RIGHT_HAND].GetPosition(); + } else if (dir == -1) { + target[LEFT_HAND] = servo_[LEFT_HAND].GetPosition(); + } + + MoveServos(period, target); +} + +//--------------------------------------------------------- +//-- 手部动作: 挥手 +//-- Parameters: +//-- period: 动作周期 +//-- dir: 方向 LEFT/RIGHT/BOTH +//--------------------------------------------------------- +void Otto::HandWave(int period, int dir) { + if (!has_hands_) { + return; + } + + if (dir == BOTH) { + HandWaveBoth(period); + return; + } + + int servo_index = (dir == LEFT) ? LEFT_HAND : RIGHT_HAND; + + int current_positions[SERVO_COUNT]; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + current_positions[i] = servo_[i].GetPosition(); + } else { + current_positions[i] = 90; + } + } + + int position; + if (servo_index == LEFT_HAND) { + position = 170; + } else { + position = 10; + } + + current_positions[servo_index] = position; + MoveServos(300, current_positions); + vTaskDelay(pdMS_TO_TICKS(300)); + + // 左右摆动5次 + for (int i = 0; i < 5; i++) { + if (servo_index == LEFT_HAND) { + current_positions[servo_index] = position - 30; + MoveServos(period / 10, current_positions); + vTaskDelay(pdMS_TO_TICKS(period / 10)); + current_positions[servo_index] = position + 30; + MoveServos(period / 10, current_positions); + } else { + current_positions[servo_index] = position + 30; + MoveServos(period / 10, current_positions); + vTaskDelay(pdMS_TO_TICKS(period / 10)); + current_positions[servo_index] = position - 30; + MoveServos(period / 10, current_positions); + } + vTaskDelay(pdMS_TO_TICKS(period / 10)); + } + + if (servo_index == LEFT_HAND) { + current_positions[servo_index] = HAND_HOME_POSITION; + } else { + current_positions[servo_index] = 180 - HAND_HOME_POSITION; + } + MoveServos(300, current_positions); +} + +//--------------------------------------------------------- +//-- 手部动作: 双手同时挥手 +//-- Parameters: +//-- period: 动作周期 +//--------------------------------------------------------- +void Otto::HandWaveBoth(int period) { + if (!has_hands_) { + return; + } + + int current_positions[SERVO_COUNT]; + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + current_positions[i] = servo_[i].GetPosition(); + } else { + current_positions[i] = 90; + } + } + + int left_position = 170; + int right_position = 10; + + current_positions[LEFT_HAND] = left_position; + current_positions[RIGHT_HAND] = right_position; + MoveServos(300, current_positions); + + // 左右摆动5次 + for (int i = 0; i < 5; i++) { + // 波浪向左 + current_positions[LEFT_HAND] = left_position - 30; + current_positions[RIGHT_HAND] = right_position + 30; + MoveServos(period / 10, current_positions); + + // 波浪向右 + current_positions[LEFT_HAND] = left_position + 30; + current_positions[RIGHT_HAND] = right_position - 30; + MoveServos(period / 10, current_positions); + } + + current_positions[LEFT_HAND] = HAND_HOME_POSITION; + current_positions[RIGHT_HAND] = 180 - HAND_HOME_POSITION; + MoveServos(300, current_positions); +} + +void Otto::EnableServoLimit(int diff_limit) { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].SetLimiter(diff_limit); + } + } +} + +void Otto::DisableServoLimit() { + for (int i = 0; i < SERVO_COUNT; i++) { + if (servo_pins_[i] != -1) { + servo_[i].DisableLimiter(); + } + } +} diff --git a/main/boards/otto-robot/otto_movements.h b/main/boards/otto-robot/otto_movements.h new file mode 100644 index 00000000..cab13a05 --- /dev/null +++ b/main/boards/otto-robot/otto_movements.h @@ -0,0 +1,105 @@ +#ifndef __OTTO_MOVEMENTS_H__ +#define __OTTO_MOVEMENTS_H__ + +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "oscillator.h" + +//-- Constants +#define FORWARD 1 +#define BACKWARD -1 +#define LEFT 1 +#define RIGHT -1 +#define BOTH 0 +#define SMALL 5 +#define MEDIUM 15 +#define BIG 30 + +// -- Servo delta limit default. degree / sec +#define SERVO_LIMIT_DEFAULT 240 + +// -- Servo indexes for easy access +#define LEFT_LEG 0 +#define RIGHT_LEG 1 +#define LEFT_FOOT 2 +#define RIGHT_FOOT 3 +#define LEFT_HAND 4 +#define RIGHT_HAND 5 +#define SERVO_COUNT 6 + +class Otto { +public: + Otto(); + ~Otto(); + + //-- Otto initialization + void Init(int left_leg, int right_leg, int left_foot, int right_foot, int left_hand = -1, + int right_hand = -1); + //-- Attach & detach functions + void AttachServos(); + void DetachServos(); + + //-- Oscillator Trims + void SetTrims(int left_leg, int right_leg, int left_foot, int right_foot, int left_hand = 0, + int right_hand = 0); + + //-- Predetermined Motion Functions + void MoveServos(int time, int servo_target[]); + void MoveSingle(int position, int servo_number); + void OscillateServos(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float cycle); + + //-- HOME = Otto at rest position + void Home(bool hands_down = true); + bool GetRestState(); + void SetRestState(bool state); + + //-- Predetermined Motion Functions + void Jump(float steps = 1, int period = 2000); + + void Walk(float steps = 4, int period = 1000, int dir = FORWARD, int amount = 0); + void Turn(float steps = 4, int period = 2000, int dir = LEFT, int amount = 0); + void Bend(int steps = 1, int period = 1400, int dir = LEFT); + void ShakeLeg(int steps = 1, int period = 2000, int dir = RIGHT); + + void UpDown(float steps = 1, int period = 1000, int height = 20); + void Swing(float steps = 1, int period = 1000, int height = 20); + void TiptoeSwing(float steps = 1, int period = 900, int height = 20); + void Jitter(float steps = 1, int period = 500, int height = 20); + void AscendingTurn(float steps = 1, int period = 900, int height = 20); + + void Moonwalker(float steps = 1, int period = 900, int height = 20, int dir = LEFT); + void Crusaito(float steps = 1, int period = 900, int height = 20, int dir = FORWARD); + void Flapping(float steps = 1, int period = 1000, int height = 20, int dir = FORWARD); + + // -- 手部动作 + void HandsUp(int period = 1000, int dir = 0); // 双手举起 + void HandsDown(int period = 1000, int dir = 0); // 双手放下 + void HandWave(int period = 1000, int dir = LEFT); // 挥手 + void HandWaveBoth(int period = 1000); // 双手同时挥手 + + // -- Servo limiter + void EnableServoLimit(int speed_limit_degree_per_sec = SERVO_LIMIT_DEFAULT); + void DisableServoLimit(); + +private: + Oscillator servo_[SERVO_COUNT]; + + int servo_pins_[SERVO_COUNT]; + int servo_trim_[SERVO_COUNT]; + + unsigned long final_time_; + unsigned long partial_time_; + float increment_[SERVO_COUNT]; + + bool is_otto_resting_; + bool has_hands_; // 是否有手部舵机 + + void Execute(int amplitude[SERVO_COUNT], int offset[SERVO_COUNT], int period, + double phase_diff[SERVO_COUNT], float steps); +}; + +#endif // __OTTO_MOVEMENTS_H__ \ No newline at end of file diff --git a/main/boards/otto-robot/otto_robot.cc b/main/boards/otto-robot/otto_robot.cc new file mode 100644 index 00000000..62629da0 --- /dev/null +++ b/main/boards/otto-robot/otto_robot.cc @@ -0,0 +1,138 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "application.h" +#include "audio_codecs/no_audio_codec.h" +#include "button.h" +#include "config.h" +#include "display/lcd_display.h" +#include "iot/thing_manager.h" +#include "lamp_controller.h" +#include "led/single_led.h" +#include "mcp_server.h" +#include "otto_emoji_display.h" +#include "power_manager.h" +#include "system_reset.h" +#include "wifi_board.h" + +#define TAG "OttoRobot" + +LV_FONT_DECLARE(font_puhui_16_4); +LV_FONT_DECLARE(font_awesome_16_4); + +extern void InitializeOttoController(); + +class OttoRobot : public WifiBoard { +private: + LcdDisplay* display_; + PowerManager* power_manager_; + Button boot_button_; + void InitializePowerManager() { + power_manager_ = + new PowerManager(POWER_CHARGE_DETECT_PIN, POWER_ADC_UNIT, POWER_ADC_CHANNEL); + } + + void InitializeSpi() { + spi_bus_config_t buscfg = {}; + buscfg.mosi_io_num = DISPLAY_MOSI_PIN; + buscfg.miso_io_num = GPIO_NUM_NC; + buscfg.sclk_io_num = DISPLAY_CLK_PIN; + buscfg.quadwp_io_num = GPIO_NUM_NC; + buscfg.quadhd_io_num = GPIO_NUM_NC; + buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t); + ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO)); + } + + void InitializeLcdDisplay() { + esp_lcd_panel_io_handle_t panel_io = nullptr; + esp_lcd_panel_handle_t panel = nullptr; + ESP_LOGD(TAG, "Install panel IO"); + esp_lcd_panel_io_spi_config_t io_config = {}; + io_config.cs_gpio_num = DISPLAY_CS_PIN; + io_config.dc_gpio_num = DISPLAY_DC_PIN; + io_config.spi_mode = DISPLAY_SPI_MODE; + io_config.pclk_hz = 40 * 1000 * 1000; + io_config.trans_queue_depth = 10; + io_config.lcd_cmd_bits = 8; + io_config.lcd_param_bits = 8; + ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &panel_io)); + + ESP_LOGD(TAG, "Install LCD driver"); + esp_lcd_panel_dev_config_t panel_config = {}; + panel_config.reset_gpio_num = DISPLAY_RST_PIN; + panel_config.rgb_ele_order = DISPLAY_RGB_ORDER; + panel_config.bits_per_pixel = 16; + + ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel)); + + esp_lcd_panel_reset(panel); + + esp_lcd_panel_init(panel); + esp_lcd_panel_invert_color(panel, DISPLAY_INVERT_COLOR); + esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY); + esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y); + + display_ = new OttoEmojiDisplay( + panel_io, panel, DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, + DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY, + { + .text_font = &font_puhui_16_4, + .icon_font = &font_awesome_16_4, + .emoji_font = DISPLAY_HEIGHT >= 240 ? font_emoji_64_init() : font_emoji_32_init(), + }); + } + + void InitializeButtons() { + boot_button_.OnClick([this]() { + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateStarting && + !WifiStation::GetInstance().IsConnected()) { + ResetWifiConfiguration(); + } + app.ToggleChatState(); + }); + } + + void InitializeOttoController() { + ESP_LOGI(TAG, "初始化Otto机器人MCP控制器"); + ::InitializeOttoController(); + } + +public: + OttoRobot() : boot_button_(BOOT_BUTTON_GPIO) { + InitializeSpi(); + InitializeLcdDisplay(); + InitializeButtons(); + InitializePowerManager(); + InitializeOttoController(); + GetBacklight()->RestoreBrightness(); + } + + virtual AudioCodec* GetAudioCodec() override { + static NoAudioCodecSimplex audio_codec(AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_I2S_SPK_GPIO_BCLK, AUDIO_I2S_SPK_GPIO_LRCK, + AUDIO_I2S_SPK_GPIO_DOUT, AUDIO_I2S_MIC_GPIO_SCK, + AUDIO_I2S_MIC_GPIO_WS, AUDIO_I2S_MIC_GPIO_DIN); + return &audio_codec; + } + + virtual Display* GetDisplay() override { return display_; } + + virtual Backlight* GetBacklight() override { + static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT); + return &backlight; + } + virtual bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override { + charging = power_manager_->IsCharging(); + discharging = !charging; + level = power_manager_->GetBatteryLevel(); + return true; + } +}; + +DECLARE_BOARD(OttoRobot); diff --git a/main/boards/otto-robot/power_manager.h b/main/boards/otto-robot/power_manager.h new file mode 100644 index 00000000..13d8ff3b --- /dev/null +++ b/main/boards/otto-robot/power_manager.h @@ -0,0 +1,128 @@ +#ifndef __POWER_MANAGER_H__ +#define __POWER_MANAGER_H__ + +#include +#include +#include +#include + +class PowerManager { +private: + // 电池电量区间-分压电阻为2个100k + static constexpr struct { + uint16_t adc; + uint8_t level; + } BATTERY_LEVELS[] = {{2150, 0}, {2450, 100}}; + static constexpr size_t BATTERY_LEVELS_COUNT = 2; + static constexpr size_t ADC_VALUES_COUNT = 10; + + esp_timer_handle_t timer_handle_ = nullptr; + gpio_num_t charging_pin_; + adc_unit_t adc_unit_; + adc_channel_t adc_channel_; + uint16_t adc_values_[ADC_VALUES_COUNT]; + size_t adc_values_index_ = 0; + size_t adc_values_count_ = 0; + uint8_t battery_level_ = 100; + bool is_charging_ = false; + + adc_oneshot_unit_handle_t adc_handle_; + + void CheckBatteryStatus() { + is_charging_ = gpio_get_level(charging_pin_) == 0; + ReadBatteryAdcData(); + } + + void ReadBatteryAdcData() { + int adc_value; + ESP_ERROR_CHECK(adc_oneshot_read(adc_handle_, adc_channel_, &adc_value)); + + adc_values_[adc_values_index_] = adc_value; + adc_values_index_ = (adc_values_index_ + 1) % ADC_VALUES_COUNT; + if (adc_values_count_ < ADC_VALUES_COUNT) { + adc_values_count_++; + } + + uint32_t average_adc = 0; + for (size_t i = 0; i < adc_values_count_; i++) { + average_adc += adc_values_[i]; + } + average_adc /= adc_values_count_; + + CalculateBatteryLevel(average_adc); + + // ESP_LOGI("PowerManager", "ADC值: %d 平均值: %ld 电量: %u%%", adc_value, average_adc, + // battery_level_); + } + + void CalculateBatteryLevel(uint32_t average_adc) { + if (average_adc <= BATTERY_LEVELS[0].adc) { + battery_level_ = 0; + } else if (average_adc >= BATTERY_LEVELS[BATTERY_LEVELS_COUNT - 1].adc) { + battery_level_ = 100; + } else { + float ratio = static_cast(average_adc - BATTERY_LEVELS[0].adc) / + (BATTERY_LEVELS[1].adc - BATTERY_LEVELS[0].adc); + battery_level_ = ratio * 100; + } + } + +public: + PowerManager(gpio_num_t charging_pin, adc_unit_t adc_unit = ADC_UNIT_2, + adc_channel_t adc_channel = ADC_CHANNEL_3) + : charging_pin_(charging_pin), adc_unit_(adc_unit), adc_channel_(adc_channel) { + gpio_config_t io_conf = {}; + io_conf.intr_type = GPIO_INTR_DISABLE; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pin_bit_mask = (1ULL << charging_pin_); + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.pull_up_en = GPIO_PULLUP_ENABLE; + gpio_config(&io_conf); + + esp_timer_create_args_t timer_args = { + .callback = + [](void* arg) { + PowerManager* self = static_cast(arg); + self->CheckBatteryStatus(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "battery_check_timer", + .skip_unhandled_events = true, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer_handle_)); + ESP_ERROR_CHECK(esp_timer_start_periodic(timer_handle_, 1000000)); // 1秒 + + InitializeAdc(); + } + + void InitializeAdc() { + adc_oneshot_unit_init_cfg_t init_config = { + .unit_id = adc_unit_, + .ulp_mode = ADC_ULP_MODE_DISABLE, + }; + ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config, &adc_handle_)); + + adc_oneshot_chan_cfg_t chan_config = { + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_12, + }; + + ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle_, adc_channel_, &chan_config)); + } + + ~PowerManager() { + if (timer_handle_) { + esp_timer_stop(timer_handle_); + esp_timer_delete(timer_handle_); + } + if (adc_handle_) { + adc_oneshot_del_unit(adc_handle_); + } + } + + bool IsCharging() { return is_charging_; } + + uint8_t GetBatteryLevel() { return battery_level_; } +}; +#endif // __POWER_MANAGER_H__ \ No newline at end of file diff --git a/main/idf_component.yml b/main/idf_component.yml index f86cbd3f..aecfa608 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -31,6 +31,7 @@ dependencies: espressif2022/image_player: ^1.1.0 espressif/adc_mic: ^0.2.0 espressif/esp_mmap_assets: ">=1.2" + txp666/otto-emoji-gif-component: ~1.0.2 tny-robotics/sh1106-esp-idf: version: ^1.0.0