diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 02c1d394..73f07b37 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -88,6 +88,8 @@ elseif(CONFIG_BOARD_TYPE_ESP32S3_KORVO2_V3) set(BOARD_TYPE "esp32s3-korvo2-v3") elseif(CONFIG_BOARD_TYPE_ESP_SPARKBOT) set(BOARD_TYPE "esp-sparkbot") +elseif(CONFIG_BOARD_TYPE_ESP_SPOT_S3) + set(BOARD_TYPE "esp-spot-s3") elseif(CONFIG_BOARD_TYPE_ESP32S3_Touch_AMOLED_1_8) set(BOARD_TYPE "esp32-s3-touch-amoled-1.8") elseif(CONFIG_BOARD_TYPE_ESP32S3_Touch_LCD_1_85C) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 0bea3199..b0213a42 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -114,6 +114,8 @@ choice BOARD_TYPE bool "ESP32S3_KORVO2_V3开发板" config BOARD_TYPE_ESP_SPARKBOT bool "ESP-SparkBot开发板" + config BOARD_TYPE_ESP_SPOT_S3 + bool "ESP-Spot-S3" config BOARD_TYPE_ESP32S3_Touch_AMOLED_1_8 bool "Waveshare ESP32-S3-Touch-AMOLED-1.8" config BOARD_TYPE_ESP32S3_Touch_LCD_1_85C diff --git a/main/boards/esp-spot-s3/README.md b/main/boards/esp-spot-s3/README.md new file mode 100644 index 00000000..1d739a74 --- /dev/null +++ b/main/boards/esp-spot-s3/README.md @@ -0,0 +1,57 @@ +# ESP-Spot S3 + +## 简介 + +
+ 立创开源平台 + | + Bilibili Demo +
+ +ESP-Spot 是 ESP Friends 开源的一款智能语音交互盒子,内置麦克风、扬声器、IMU 惯性传感器,可使用电池供电。ESP-Spot 不带屏幕,带有一个 RGB 指示灯和两个按钮。硬件详情可查看[立创开源项目](https://oshwhub.com/esp-college/esp-spot)。 + +ESP-Spot 开源项目采用 ESP32-S3-WROOM-1-N16R8 模组。如在复刻时使用了其他大小的 Flash,需修改对应的参数。 + + +## 配置、编译命令 + +**配置编译目标为 ESP32S3** + +```bash +idf.py set-target esp32s3 +``` + +**打开 menuconfig 并配置** + +```bash +idf.py menuconfig +``` + +分别配置如下选项: + +- `Xiaozhi Assistant` → `Board Type` → 选择 `ESP-Spot-S3` +- `Partition Table` → `Custom partition CSV file` → 输入 `partitions.csv` +- `Serial flasher config` → `Flash size` → 选择 `16 MB` + +按 `S` 保存,按 `Q` 退出。 + +**编译** + +```bash +idf.py build +``` + +**烧录** + +```bash +idf.py flash +``` + +> [!TIP] +> +> **若电脑始终无法找到 ESP-Spot 串口,可尝试如下步骤** +> 1. 打开前盖; +> 2. 拔出带有模组的 PCB 板; +> 3. 按住 BOOT 同时插回 PCB 版,注意不要颠倒; +> +> 此时, ESP-Spot 应当已进入下载模式。在烧录完成后,可能需要重新插拔 PCB 板。 diff --git a/main/boards/esp-spot-s3/config.h b/main/boards/esp-spot-s3/config.h new file mode 100644 index 00000000..0acf05cc --- /dev/null +++ b/main/boards/esp-spot-s3/config.h @@ -0,0 +1,35 @@ +#ifndef _BOARD_CONFIG_H_ +#define _BOARD_CONFIG_H_ + +#include + +#define AUDIO_INPUT_SAMPLE_RATE 16000 +#define AUDIO_OUTPUT_SAMPLE_RATE 16000 + +#define AUDIO_INPUT_REFERENCE false + +#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_8 +#define AUDIO_I2S_GPIO_WS GPIO_NUM_17 +#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_16 +#define AUDIO_I2S_GPIO_DIN GPIO_NUM_15 +#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_18 + +#define AUDIO_CODEC_PA_PIN GPIO_NUM_40 +#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_2 +#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_1 +#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR + +#define BOOT_BUTTON_GPIO GPIO_NUM_0 +#define KEY_BUTTON_GPIO GPIO_NUM_12 +#define LED_PIN GPIO_NUM_11 + +#define VBAT_ADC_CHANNEL ADC_CHANNEL_9 // S3: IO10 +#define MCU_VCC_CTL GPIO_NUM_4 // set 1 to power on MCU +#define PERP_VCC_CTL GPIO_NUM_6 // set 1 to power on peripherals + +#define ADC_ATTEN ADC_ATTEN_DB_12 +#define ADC_WIDTH ADC_BITWIDTH_DEFAULT +#define FULL_BATTERY_VOLTAGE 4100 +#define EMPTY_BATTERY_VOLTAGE 3200 + +#endif // _BOARD_CONFIG_H_ diff --git a/main/boards/esp-spot-s3/esp_spot_s3_board.cc b/main/boards/esp-spot-s3/esp_spot_s3_board.cc new file mode 100644 index 00000000..af8f5fa1 --- /dev/null +++ b/main/boards/esp-spot-s3/esp_spot_s3_board.cc @@ -0,0 +1,251 @@ +#include "wifi_board.h" +#include "audio_codecs/es8311_audio_codec.h" +#include "application.h" +#include "button.h" +#include "config.h" +#include "iot/thing_manager.h" +#include "sdkconfig.h" + +#include +#include +#include +#include +#include "esp_adc/adc_oneshot.h" +#include "esp_adc/adc_cali.h" +#include "esp_adc/adc_cali_scheme.h" + +#include +#include "esp_timer.h" +#include "led/circular_strip.h" + +#define TAG "esp_spot_s3" + +bool button_released_ = false; +bool shutdown_ready_ = false; +esp_timer_handle_t shutdown_timer; + +class EspSpotS3Bot : public WifiBoard { +private: + i2c_master_bus_handle_t i2c_bus_; + Button boot_button_; + Button key_button_; + adc_oneshot_unit_handle_t adc1_handle; + adc_cali_handle_t adc1_cali_handle; + bool do_calibration = false; + bool key_long_pressed = false; + int64_t last_key_press_time = 0; + static const int64_t LONG_PRESS_TIMEOUT_US = 5 * 1000000ULL; + + void InitializeI2c() { + // Initialize I2C peripheral + i2c_master_bus_config_t i2c_bus_cfg = { + .i2c_port = I2C_NUM_0, + .sda_io_num = AUDIO_CODEC_I2C_SDA_PIN, + .scl_io_num = AUDIO_CODEC_I2C_SCL_PIN, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .intr_priority = 0, + .trans_queue_depth = 0, + .flags = { + .enable_internal_pullup = 1, + }, + }; + ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &i2c_bus_)); + } + + void InitializeADC() { + adc_oneshot_unit_init_cfg_t init_config1 = { + .unit_id = ADC_UNIT_1 + }; + ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config1, &adc1_handle)); + + adc_oneshot_chan_cfg_t chan_config = { + .atten = ADC_ATTEN, + .bitwidth = ADC_WIDTH, + }; + ESP_ERROR_CHECK(adc_oneshot_config_channel(adc1_handle, VBAT_ADC_CHANNEL, &chan_config)); + + adc_cali_handle_t handle = NULL; + esp_err_t ret = ESP_FAIL; + +#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED + adc_cali_curve_fitting_config_t cali_config = { + .unit_id = ADC_UNIT_1, + .atten = ADC_ATTEN, + .bitwidth = ADC_WIDTH, + }; + ret = adc_cali_create_scheme_curve_fitting(&cali_config, &handle); + if (ret == ESP_OK) { + do_calibration = true; + adc1_cali_handle = handle; + ESP_LOGI(TAG, "ADC Curve Fitting calibration succeeded"); + } +#endif // ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED + } + + void InitializeButtons() { + boot_button_.OnClick([this]() { + auto& app = Application::GetInstance(); + ResetWifiConfiguration(); + }); + + key_button_.OnClick([this]() { + auto& app = Application::GetInstance(); + app.ToggleChatState(); + key_long_pressed = false; + }); + + key_button_.OnLongPress([this]() { + int64_t now = esp_timer_get_time(); + auto* led = static_cast(this->GetLed()); + + if (key_long_pressed) { + if ((now - last_key_press_time) < LONG_PRESS_TIMEOUT_US) { + ESP_LOGW(TAG, "Key button long pressed the second time within 5s, shutting down..."); + led->SetSingleColor(0, {0, 0, 0}); + + gpio_hold_dis(MCU_VCC_CTL); + gpio_set_level(MCU_VCC_CTL, 0); + + } else { + last_key_press_time = now; + BlinkGreenFor5s(); + } + key_long_pressed = true; + } else { + ESP_LOGW(TAG, "Key button first long press! Waiting second within 5s to shutdown..."); + last_key_press_time = now; + key_long_pressed = true; + + BlinkGreenFor5s(); + } + }); + } + + void InitializePowerCtl() { + InitializeGPIO(); + + gpio_set_level(MCU_VCC_CTL, 1); + gpio_hold_en(MCU_VCC_CTL); + + gpio_set_level(PERP_VCC_CTL, 1); + gpio_hold_en(PERP_VCC_CTL); + } + + void InitializeGPIO() { + gpio_config_t io_pa = { + .pin_bit_mask = (1ULL << AUDIO_CODEC_PA_PIN), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE + }; + gpio_config(&io_pa); + gpio_set_level(AUDIO_CODEC_PA_PIN, 0); + + gpio_config_t io_conf_1 = { + .pin_bit_mask = (1ULL << MCU_VCC_CTL), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE + }; + gpio_config(&io_conf_1); + + gpio_config_t io_conf_2 = { + .pin_bit_mask = (1ULL << PERP_VCC_CTL), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE + }; + gpio_config(&io_conf_2); + } + + void InitializeIot() { + auto& thing_manager = iot::ThingManager::GetInstance(); + thing_manager.AddThing(iot::CreateThing("Speaker")); + thing_manager.AddThing(iot::CreateThing("Battery")); + } + + + void BlinkGreenFor5s() { + auto* led = static_cast(GetLed()); + if (!led) { + return; + } + + led->Blink({50, 25, 0}, 100); + + esp_timer_create_args_t timer_args = { + .callback = [](void* arg) { + auto* self = static_cast(arg); + auto* led = static_cast(self->GetLed()); + if (led) { + led->SetSingleColor(0, {0, 0, 0}); + } + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "blinkGreenFor5s_timer" + }; + + esp_timer_handle_t blink_timer = nullptr; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &blink_timer)); + ESP_ERROR_CHECK(esp_timer_start_once(blink_timer, LONG_PRESS_TIMEOUT_US)); + } + +public: + EspSpotS3Bot() : boot_button_(BOOT_BUTTON_GPIO), key_button_(KEY_BUTTON_GPIO, true) { + InitializePowerCtl(); + InitializeADC(); + InitializeI2c(); + InitializeButtons(); + InitializeIot(); + } + + virtual Led* GetLed() override { + static CircularStrip led(LED_PIN, 1); + return &led; + } + + virtual AudioCodec* GetAudioCodec() override { + static Es8311AudioCodec audio_codec(i2c_bus_, I2C_NUM_0, + AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE, AUDIO_I2S_GPIO_MCLK, AUDIO_I2S_GPIO_BCLK, + AUDIO_I2S_GPIO_WS, AUDIO_I2S_GPIO_DOUT, AUDIO_I2S_GPIO_DIN, AUDIO_CODEC_PA_PIN, + AUDIO_CODEC_ES8311_ADDR, false); + return &audio_codec; + } + + virtual bool GetBatteryLevel(int &level, bool &charging, bool &discharging) { + if (!adc1_handle) { + InitializeADC(); + } + + int raw_value = 0; + int voltage = 0; + + ESP_ERROR_CHECK(adc_oneshot_read(adc1_handle, VBAT_ADC_CHANNEL, &raw_value)); + + if (do_calibration) { + ESP_ERROR_CHECK(adc_cali_raw_to_voltage(adc1_cali_handle, raw_value, &voltage)); + voltage = voltage * 3 / 2; // compensate for voltage divider + ESP_LOGI(TAG, "Calibrated voltage: %d mV", voltage); + } else { + ESP_LOGI(TAG, "Raw ADC value: %d", raw_value); + voltage = raw_value; + } + + voltage = voltage < EMPTY_BATTERY_VOLTAGE ? EMPTY_BATTERY_VOLTAGE : voltage; + voltage = voltage > FULL_BATTERY_VOLTAGE ? FULL_BATTERY_VOLTAGE : voltage; + + // 计算电量百分比 + level = (voltage - EMPTY_BATTERY_VOLTAGE) * 100 / (FULL_BATTERY_VOLTAGE - EMPTY_BATTERY_VOLTAGE); + + charging = gpio_get_level(MCU_VCC_CTL); + ESP_LOGI(TAG, "Battery Level: %d%%, Charging: %s", level, charging ? "Yes" : "No"); + return true; + } +}; + +DECLARE_BOARD(EspSpotS3Bot);