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
+
+## 简介
+
+
+
+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);