#include "wifi_board.h"
|
#include "codecs/es8311_audio_codec.h"
|
#include "display/lcd_display.h"
|
#include "application.h"
|
#include "button.h"
|
#include "config.h"
|
#include "led/single_led.h"
|
#include "mcp_server.h"
|
#include "power_save_timer.h"
|
#include "adc_battery_monitor.h"
|
#include "wifi_manager.h"
|
|
#include <esp_log.h>
|
#include <esp_efuse_table.h>
|
#include <esp_timer.h>
|
#include <esp_heap_caps.h>
|
#include <driver/i2c_master.h>
|
#include <freertos/event_groups.h>
|
#include <cJSON.h>
|
#include <mbedtls/md5.h>
|
|
#include <algorithm>
|
#include <cctype>
|
#include <cmath>
|
#include <cstdio>
|
#include <cstring>
|
#include <string>
|
|
#include <esp_lcd_panel_io.h>
|
#include <esp_lcd_panel_ops.h>
|
#include <esp_lcd_gc9d01.h>
|
|
#include "driver/gpio.h"
|
#include "driver/spi_master.h"
|
#include "nvs_flash.h"
|
#include "esp_vfs_fat.h"
|
#include "sdmmc_cmd.h"
|
#include "driver/sdmmc_host.h"
|
|
#include <ds3231.h>
|
#include "system.h"
|
#include "mqtt.h"
|
|
#define TAG "sening_xiaozhi_s3"
|
#define WIFI_RECONNECT_CONNECTED_BIT BIT0
|
#define WIFI_RECONNECT_FAILED_BIT BIT1
|
#define WIFI_RECONNECT_TIMEOUT_MS 30000
|
|
static esp_lcd_panel_handle_t s_board_panel_handle = NULL;
|
|
extern "C" esp_err_t Board_Set_Output_Volume(int level)
|
{
|
if (level < 0 || level > 100) {
|
return ESP_ERR_INVALID_ARG;
|
}
|
|
auto codec = Board::GetInstance().GetAudioCodec();
|
if (!codec) {
|
return ESP_ERR_INVALID_STATE;
|
}
|
codec->SetOutputVolume(level);
|
return ESP_OK;
|
}
|
|
extern "C" int Board_Get_Output_Volume(void)
|
{
|
auto codec = Board::GetInstance().GetAudioCodec();
|
if (!codec) {
|
return -1;
|
}
|
return codec->output_volume();
|
}
|
|
extern "C" esp_err_t Board_Enter_Standby(void)
|
{
|
Application::GetInstance().EnterStandby();
|
return ESP_OK;
|
}
|
|
extern "C" esp_err_t Board_Request_Wake(int timeout_ms)
|
{
|
if (timeout_ms < 0) {
|
timeout_ms = 0;
|
}
|
return Board::GetInstance().EnsureNetworkReadyForWake(timeout_ms) ? ESP_OK : ESP_FAIL;
|
}
|
|
extern "C" esp_err_t Board_Reboot_Delayed(int delay_seconds)
|
{
|
if (delay_seconds < 0 || delay_seconds > 3600) {
|
return ESP_ERR_INVALID_ARG;
|
}
|
|
BaseType_t ret = xTaskCreate([](void *arg) {
|
int delay = (int)(intptr_t)arg;
|
if (delay > 0) {
|
vTaskDelay(pdMS_TO_TICKS(delay * 1000));
|
}
|
ESP_LOGW(TAG, "MQTT requested reboot after %d seconds", delay);
|
Application::GetInstance().Reboot();
|
vTaskDelete(nullptr);
|
}, "mqtt_reboot", 4096, (void *)(intptr_t)delay_seconds, 5, nullptr);
|
|
return ret == pdPASS ? ESP_OK : ESP_FAIL;
|
}
|
|
extern "C" void Board_Acquire_Sleep_Inhibit(void)
|
{
|
Application::GetInstance().AcquireSleepInhibit();
|
}
|
|
extern "C" void Board_Release_Sleep_Inhibit(void)
|
{
|
Application::GetInstance().ReleaseSleepInhibit();
|
}
|
|
extern "C" bool Board_Is_Voice_Session_Active(void)
|
{
|
DeviceState state = Application::GetInstance().GetDeviceState();
|
return state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking;
|
}
|
|
extern "C" void Board_Show_JPG(const char *jpg_name)
|
{
|
if (!jpg_name || jpg_name[0] == '\0') {
|
return;
|
}
|
SDCard_Play_JPG(s_board_panel_handle, const_cast<char *>(jpg_name));
|
}
|
|
extern "C" void Board_Show_Black_Screen(void)
|
{
|
if (!s_board_panel_handle) {
|
return;
|
}
|
|
uint16_t black_line[SCREEN_WIDTH] = {0};
|
for (int y = 0; y < SCREEN_HEIGHT; ++y) {
|
esp_err_t err = esp_lcd_panel_draw_bitmap(s_board_panel_handle, 0, y, SCREEN_WIDTH, y + 1, black_line);
|
if (err != ESP_OK) {
|
ESP_LOGW(TAG, "clear screen failed at line %d: %s", y, esp_err_to_name(err));
|
return;
|
}
|
}
|
}
|
|
extern "C" void Board_Show_Alert(const char *status, const char *message, const char *emotion)
|
{
|
if (!status || !message) {
|
return;
|
}
|
std::string status_text(status);
|
std::string message_text(message);
|
std::string emotion_text(emotion ? emotion : "");
|
Application::GetInstance().Schedule([status_text, message_text, emotion_text]() {
|
Application::GetInstance().Alert(status_text.c_str(), message_text.c_str(), emotion_text.c_str());
|
});
|
}
|
|
extern "C" esp_err_t Board_Start_Firmware_Upgrade(const char *url,
|
const char *version,
|
board_ota_progress_cb_t progress_cb,
|
board_ota_complete_cb_t complete_cb,
|
void *ctx)
|
{
|
if (!url || url[0] == '\0') {
|
return ESP_ERR_INVALID_ARG;
|
}
|
|
std::string upgrade_url(url);
|
std::string upgrade_version(version ? version : "");
|
Application::GetInstance().Schedule([upgrade_url, upgrade_version, progress_cb, complete_cb, ctx]() {
|
Application::GetInstance().UpgradeFirmware(upgrade_url, upgrade_version,
|
[progress_cb, ctx](int progress, size_t speed) {
|
if (progress_cb) {
|
progress_cb(progress, speed, ctx);
|
}
|
},
|
[complete_cb, ctx](bool success) {
|
if (complete_cb) {
|
complete_cb(success, ctx);
|
}
|
});
|
});
|
return ESP_OK;
|
}
|
|
static void SetDownloadError(char *error_info, size_t error_info_size, const char *message)
|
{
|
if (error_info && error_info_size > 0) {
|
snprintf(error_info, error_info_size, "%s", message ? message : "download failed");
|
}
|
}
|
|
static std::string Md5ToHex(const unsigned char digest[16])
|
{
|
static const char hex[] = "0123456789abcdef";
|
std::string value;
|
value.resize(32);
|
for (int i = 0; i < 16; ++i) {
|
value[i * 2] = hex[(digest[i] >> 4) & 0x0f];
|
value[i * 2 + 1] = hex[digest[i] & 0x0f];
|
}
|
return value;
|
}
|
|
static bool Md5Equals(const std::string& actual, const char *expected)
|
{
|
if (!expected || expected[0] == '\0') {
|
return true;
|
}
|
std::string normalized(expected);
|
std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char c) {
|
return static_cast<char>(std::tolower(c));
|
});
|
return actual == normalized;
|
}
|
|
extern "C" esp_err_t Board_Download_File_To_Path(const char *url,
|
const char *part_path,
|
const char *expected_md5,
|
board_download_progress_cb_t progress_cb,
|
void *ctx,
|
char *error_info,
|
size_t error_info_size)
|
{
|
if (!url || !part_path || url[0] == '\0' || part_path[0] == '\0') {
|
SetDownloadError(error_info, error_info_size, "invalid download argument");
|
return ESP_ERR_INVALID_ARG;
|
}
|
|
FILE *fp = fopen(part_path, "wb");
|
if (!fp) {
|
ESP_LOGE(TAG, "open part file failed: %s", part_path);
|
SetDownloadError(error_info, error_info_size, "open part file failed");
|
return ESP_FAIL;
|
}
|
|
auto network = Board::GetInstance().GetNetwork();
|
auto http = network->CreateHttp(0);
|
http->SetTimeout(30000);
|
if (!http->Open("GET", url)) {
|
fclose(fp);
|
SetDownloadError(error_info, error_info_size, "open http failed");
|
return ESP_FAIL;
|
}
|
|
int status = http->GetStatusCode();
|
if (status != 200) {
|
fclose(fp);
|
http->Close();
|
char message[64];
|
snprintf(message, sizeof(message), "http status %d", status);
|
SetDownloadError(error_info, error_info_size, message);
|
return ESP_FAIL;
|
}
|
|
size_t content_length = http->GetBodyLength();
|
constexpr size_t BUFFER_SIZE = 1024;
|
char *buffer = (char *)heap_caps_malloc(BUFFER_SIZE, MALLOC_CAP_INTERNAL);
|
if (!buffer) {
|
fclose(fp);
|
http->Close();
|
SetDownloadError(error_info, error_info_size, "no memory for download buffer");
|
return ESP_ERR_NO_MEM;
|
}
|
|
mbedtls_md5_context md5_ctx;
|
bool check_md5 = expected_md5 && expected_md5[0] != '\0';
|
if (check_md5) {
|
mbedtls_md5_init(&md5_ctx);
|
mbedtls_md5_starts(&md5_ctx);
|
}
|
|
size_t total_read = 0;
|
int last_progress = -1;
|
int64_t last_report = 0;
|
bool ok = true;
|
bool canceled = false;
|
while (true) {
|
int ret = http->Read(buffer, BUFFER_SIZE);
|
if (ret < 0) {
|
ESP_LOGE(TAG, "read HTTP failed: %d", ret);
|
SetDownloadError(error_info, error_info_size, "read http failed");
|
ok = false;
|
break;
|
}
|
if (ret == 0) {
|
break;
|
}
|
|
if (fwrite(buffer, 1, ret, fp) != (size_t)ret) {
|
ESP_LOGE(TAG, "write part file failed: %s", part_path);
|
SetDownloadError(error_info, error_info_size, "write part file failed");
|
ok = false;
|
break;
|
}
|
if (check_md5) {
|
mbedtls_md5_update(&md5_ctx, (const unsigned char *)buffer, ret);
|
}
|
|
total_read += ret;
|
if (progress_cb) {
|
int progress = content_length > 0 ? (int)(total_read * 100 / content_length) : 0;
|
int64_t now = esp_timer_get_time();
|
bool report = content_length <= 0 || progress != last_progress || now - last_report >= 1000000 || progress >= 100;
|
if (report) {
|
last_progress = progress;
|
last_report = now;
|
if (!progress_cb(progress, "downloading", ctx)) {
|
SetDownloadError(error_info, error_info_size, "download canceled");
|
canceled = true;
|
ok = false;
|
break;
|
}
|
}
|
}
|
}
|
|
if (!canceled && content_length > 0 && total_read != content_length) {
|
SetDownloadError(error_info, error_info_size, "download size mismatch");
|
ok = false;
|
}
|
if (fflush(fp) != 0) {
|
SetDownloadError(error_info, error_info_size, "flush part file failed");
|
ok = false;
|
}
|
fclose(fp);
|
http->Close();
|
heap_caps_free(buffer);
|
|
if (!ok) {
|
if (check_md5) {
|
unsigned char discard[16];
|
mbedtls_md5_finish(&md5_ctx, discard);
|
mbedtls_md5_free(&md5_ctx);
|
}
|
return ESP_FAIL;
|
}
|
|
if (check_md5) {
|
unsigned char digest[16];
|
mbedtls_md5_finish(&md5_ctx, digest);
|
mbedtls_md5_free(&md5_ctx);
|
std::string actual = Md5ToHex(digest);
|
if (!Md5Equals(actual, expected_md5)) {
|
ESP_LOGE(TAG, "md5 mismatch: actual=%s expected=%s", actual.c_str(), expected_md5);
|
SetDownloadError(error_info, error_info_size, "md5 mismatch");
|
return ESP_FAIL;
|
}
|
}
|
|
if (progress_cb && !progress_cb(100, "downloaded", ctx)) {
|
SetDownloadError(error_info, error_info_size, "download canceled");
|
return ESP_FAIL;
|
}
|
ESP_LOGI(TAG, "download completed: %s -> %s (%u bytes)", url, part_path, (unsigned)total_read);
|
return ESP_OK;
|
}
|
|
class sening_xiaozhi_s3 : public WifiBoard {
|
private:
|
Button boot_button_;
|
i2c_master_bus_handle_t codec_i2c_bus_;
|
i2c_dev_t ds2321_dev = {};
|
esp_lcd_panel_handle_t panel_handle = NULL;
|
PowerSaveTimer* wifi_standby_timer_ = nullptr;
|
EventGroupHandle_t wifi_reconnect_event_group_ = nullptr;
|
bool wifi_standby_active_ = false;
|
bool wifi_reconnect_waiting_ = false;
|
bool power_off_active_ = false;
|
bool power_off_restore_waiting_ = false;
|
|
void OnWifiConnected(const std::string& ssid) {
|
ESP_LOGI(TAG, "WiFi connected callback, ssid: %s", ssid.c_str());
|
SNTP_To_DS2312(&ds2321_dev);
|
BoardMqtt_Start(uuid_.c_str());
|
}
|
|
void InitializeDS2321() {
|
i2cdev_init();
|
ESP_ERROR_CHECK(ds3231_init_desc(&ds2321_dev, I2C_NUM_1, RTC_I2C_SDA_PIN, RTC_I2C_SCL_PIN));
|
ESP_ERROR_CHECK(ds3231_clear_alarm_flags(&ds2321_dev, DS3231_ALARM_1));
|
DS2312_Update_Alarm(&ds2321_dev, nullptr);
|
DS2312_Start_Alarm_Monitor(&ds2321_dev);
|
}
|
|
void InitializeCodecI2c() {
|
// 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, &codec_i2c_bus_));
|
|
ESP_LOGI(TAG, "Scanning codec I2C bus...");
|
int found_count = 0;
|
for (uint8_t addr = 0x03; addr <= 0x77; ++addr) {
|
esp_err_t ret = i2c_master_probe(codec_i2c_bus_, addr, 50);
|
if (ret == ESP_OK) {
|
ESP_LOGI(TAG, "Found I2C device at address 0x%02X", addr);
|
++found_count;
|
}
|
}
|
ESP_LOGI(TAG, "Codec I2C scan complete, found %d device(s)", found_count);
|
}
|
|
void EnterWifiStandby() {
|
if (wifi_reconnect_waiting_ || wifi_standby_active_ || power_off_active_) {
|
return;
|
}
|
if (!Application::GetInstance().CanEnterSleepMode()) {
|
return;
|
}
|
|
ESP_LOGI(TAG, "Entering WiFi standby");
|
wifi_standby_active_ = true;
|
Application::GetInstance().PrepareProtocolForNetworkStandby();
|
BoardMqtt_Stop();
|
WifiManager::GetInstance().StopStation();
|
}
|
|
void SetScreenBacklight(bool enabled) {
|
gpio_set_level(SCREEN_BL_PIN, enabled ? 1 : 0);
|
}
|
|
public:
|
esp_err_t EnterPowerOffStandby() {
|
if (power_off_active_) {
|
return ESP_OK;
|
}
|
|
ESP_LOGI(TAG, "Entering board power-off standby");
|
power_off_active_ = true;
|
wifi_standby_active_ = true;
|
SetScreenBacklight(false);
|
Application::GetInstance().EnterPowerOffStandby();
|
Application::GetInstance().PrepareProtocolForNetworkStandby();
|
BoardMqtt_Stop();
|
WifiManager::GetInstance().StopStation();
|
return ESP_OK;
|
}
|
|
esp_err_t RestoreFromPowerOffStandby(int timeout_ms) {
|
if (power_off_restore_waiting_) {
|
return ESP_ERR_INVALID_STATE;
|
}
|
|
ESP_LOGI(TAG, "Restoring board from power-off standby");
|
SetScreenBacklight(true);
|
power_off_restore_waiting_ = true;
|
bool network_ready = EnsureNetworkReadyForWake(timeout_ms);
|
power_off_restore_waiting_ = false;
|
power_off_active_ = false;
|
Application::GetInstance().RestoreFromPowerOffStandby(network_ready);
|
return network_ready ? ESP_OK : ESP_FAIL;
|
}
|
|
bool IsPowerOffStandby() const {
|
return power_off_active_;
|
}
|
|
private:
|
void InitializeWifiStandbyTimer() {
|
#if WIFI_STANDBY_TIMEOUT_SEC > 0
|
wifi_reconnect_event_group_ = xEventGroupCreate();
|
wifi_standby_timer_ = new PowerSaveTimer(-1, WIFI_STANDBY_TIMEOUT_SEC, -1);
|
wifi_standby_timer_->OnEnterSleepMode([this]() {
|
xTaskCreate([](void* arg) {
|
auto* board = static_cast<sening_xiaozhi_s3*>(arg);
|
board->EnterWifiStandby();
|
vTaskDelete(NULL);
|
}, "wifi_standby", 4096, this, 2, NULL);
|
});
|
wifi_standby_timer_->SetEnabled(true);
|
#endif
|
}
|
|
void InitializeButtons() {
|
boot_button_.OnClick([this]() {
|
if (SDCard_Is_Playing()) {
|
ESP_LOGI(TAG, "Button stops MP3 playback");
|
SDCard_Stop_Play();
|
return;
|
}
|
if (BoardMqtt_CancelDownload()) {
|
ESP_LOGI(TAG, "Button cancels MQTT download");
|
return;
|
}
|
if (power_off_active_) {
|
ESP_LOGI(TAG, "Ignore button while power-off standby is active");
|
return;
|
}
|
|
auto& app = Application::GetInstance();
|
auto state = app.GetDeviceState();
|
if (state == kDeviceStateStarting) {
|
EnterWifiConfigMode();
|
return;
|
}
|
if (state == kDeviceStateWifiConfiguring) {
|
ESP_LOGI(TAG, "Button retries WiFi connection from config mode");
|
xTaskCreate([](void *arg) {
|
(void)arg;
|
WifiManager::GetInstance().StopConfigAp();
|
vTaskDelete(nullptr);
|
}, "wifi_retry", 4096, nullptr, 2, nullptr);
|
return;
|
}
|
if (state == kDeviceStateAudioTesting) {
|
app.ToggleChatState();
|
return;
|
}
|
if (!EnsureNetworkReadyForWake(WIFI_RECONNECT_TIMEOUT_MS)) {
|
return;
|
}
|
app.ToggleChatState();
|
});
|
}
|
|
// SPI初始化
|
void InitializeSpi() {
|
ESP_LOGI(TAG, "Initialize SPI bus");
|
|
spi_bus_config_t buscfg = {
|
.mosi_io_num = SCREEN_MOSI_PIN,
|
.miso_io_num = -1,
|
.sclk_io_num = SCREEN_SCK_PIN,
|
.quadwp_io_num = -1,
|
.quadhd_io_num = -1,
|
.data4_io_num = -1,
|
.data5_io_num = -1,
|
.data6_io_num = -1,
|
.data7_io_num = -1,
|
.data_io_default_level = false,
|
.max_transfer_sz = SCREEN_WIDTH * SCREEN_HEIGHT * sizeof(uint16_t),
|
.flags = 0,
|
.isr_cpu_id = ESP_INTR_CPU_AFFINITY_AUTO,
|
.intr_flags = 0,
|
};
|
|
ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO));
|
}
|
|
// GC9A01初始化
|
void InitializeGc9d01Display() {
|
ESP_LOGI(TAG, "Init GC9D01 display");
|
|
gpio_config_t bl_config = {
|
.pin_bit_mask = (1ull << SCREEN_BL_PIN),
|
.mode = GPIO_MODE_OUTPUT,
|
.pull_up_en = GPIO_PULLUP_DISABLE,
|
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
.intr_type = GPIO_INTR_DISABLE,
|
};
|
gpio_config(&bl_config);
|
|
gpio_set_level(SCREEN_BL_PIN, 1);
|
|
ESP_LOGI(TAG, "Install panel IO");
|
esp_lcd_panel_io_handle_t io_handle = NULL;
|
esp_lcd_panel_io_spi_config_t io_config = GC9D01_PANEL_IO_SPI_CONFIG(SCREEN_CS_PIN, SCREEN_DC_PIN, NULL, NULL);
|
io_config.pclk_hz = SCREEN_PI_SCLK_HZ;
|
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &io_handle));
|
|
ESP_LOGI(TAG, "Install GC9D01 panel driver");
|
esp_lcd_panel_dev_config_t panel_config = {};
|
panel_config.reset_gpio_num = SCREEN_RESET_PIN;
|
panel_config.rgb_endian = LCD_RGB_ENDIAN_BGR;
|
panel_config.bits_per_pixel = 16;
|
|
ESP_ERROR_CHECK(esp_lcd_new_panel_gc9d01(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, false));
|
ESP_ERROR_CHECK(esp_lcd_panel_mirror(panel_handle, SCREEN_MIRROR_X, SCREEN_MIRROR_Y));
|
ESP_ERROR_CHECK(esp_lcd_panel_swap_xy(panel_handle, SCREEN_SWAP_XY));
|
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));
|
|
s_board_panel_handle = panel_handle;
|
|
uint16_t *color_buf = (uint16_t*)malloc(160 * 160 * sizeof(uint16_t));
|
static int pix = 160 * 160;
|
for(int i = 0; i < pix; i++)
|
{
|
color_buf[i] = 0xF800;
|
|
}
|
esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
|
|
vTaskDelay(pdMS_TO_TICKS(500));
|
|
for(int i = 0; i < pix; i++)
|
{
|
color_buf[i] = 0x07E0;
|
|
}
|
esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
|
|
vTaskDelay(pdMS_TO_TICKS(500));
|
|
for(int i = 0; i < pix; i++)
|
{
|
color_buf[i] = 0x001F;
|
|
}
|
esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
|
|
vTaskDelay(pdMS_TO_TICKS(500));
|
|
for(int i = 0; i < pix; i++)
|
{
|
color_buf[i] = 0x0000;
|
|
}
|
esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
|
|
free(color_buf);
|
|
Board_Show_Black_Screen();
|
}
|
|
void InitializeCard() {
|
esp_vfs_fat_sdmmc_mount_config_t mount_config = {
|
.format_if_mount_failed = false, // 挂载失败时不格式化
|
.max_files = 5, // 最大同时打开文件数
|
.allocation_unit_size = 16 * 1024 // 分配单元大小16KB
|
};
|
|
sdmmc_host_t host = SDMMC_HOST_DEFAULT(); // 使用默认配置
|
host.flags = SDMMC_HOST_FLAG_4BIT;
|
host.max_freq_khz = SDMMC_FREQ_HIGHSPEED;
|
|
sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT();
|
slot_config.clk = SD_CARD_CLK_PIN;
|
slot_config.cmd = SD_CARD_CMD_PIN;
|
slot_config.d0 = SD_CARD_D0_PIN;
|
slot_config.d1 = SD_CARD_D1_PIN;
|
slot_config.d2 = SD_CARD_D2_PIN;
|
slot_config.d3 = SD_CARD_D3_PIN;
|
slot_config.width = 4;
|
|
sdmmc_card_t* card;
|
esp_err_t ret = esp_vfs_fat_sdmmc_mount(MOUNT_POINT, &host, &slot_config, &mount_config, &card);
|
|
if (ret != ESP_OK) {
|
if (ret == ESP_FAIL) {
|
ESP_LOGE("TAG", "挂载文件系统失败");
|
} else {
|
ESP_LOGE("TAG", "SD 卡初始化失败 (%s)", esp_err_to_name(ret));
|
}
|
return;
|
}
|
|
sdmmc_card_print_info(stdout, card);
|
|
uint64_t capacity_bytes = (uint64_t)card->csd.capacity * card->csd.sector_size;
|
double capacity_mib = capacity_bytes / (1024.0 * 1024.0);
|
ESP_LOGI(TAG, "Card size: %.2f MiB (%.2f GB)", capacity_mib, capacity_mib / 1024.0);
|
}
|
|
void InitializeTools() {
|
auto& mcp_server = McpServer::GetInstance();
|
|
mcp_server.AddTool("self.alarm.add",
|
"Add a daily RTC alarm at an absolute clock time. Args are the target wall-clock time of day: hour 0-23, minute 0-59, second 0-59. Do not pass relative durations directly; for requests like 'in 2 minutes', first calculate the absolute local time, then pass that time of day.",
|
PropertyList({
|
Property("hour", kPropertyTypeInteger, 0, 23),
|
Property("minute", kPropertyTypeInteger, 0, 59),
|
Property("second", kPropertyTypeInteger, 0, 0, 59)
|
}),
|
[this](const PropertyList& properties) -> ReturnValue {
|
int hour = properties["hour"].value<int>();
|
int minute = properties["minute"].value<int>();
|
int second = properties["second"].value<int>();
|
DS2312_Add_Alarm_Time(&ds2321_dev, hour, minute, second);
|
return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
|
});
|
|
mcp_server.AddTool("self.alarm.get_list",
|
"Get all daily RTC alarms as a JSON string. Each alarm has index, time, hour, min and sec fields.",
|
PropertyList(),
|
[this](const PropertyList& properties) -> ReturnValue {
|
return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
|
});
|
|
mcp_server.AddTool("self.alarm.delete",
|
"Delete a daily RTC alarm by its index from self.alarm.get_list.",
|
PropertyList({
|
Property("index", kPropertyTypeInteger, 0, ALARM_MAX_NUM - 1)
|
}),
|
[this](const PropertyList& properties) -> ReturnValue {
|
int index = properties["index"].value<int>();
|
DS2312_Delete_Alarm_Index(&ds2321_dev, static_cast<uint8_t>(index));
|
return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
|
});
|
|
mcp_server.AddTool("self.alarm.delete_all",
|
"Delete all daily RTC alarms.",
|
PropertyList(),
|
[this](const PropertyList& properties) -> ReturnValue {
|
DS2312_Delete_Alarm_All(&ds2321_dev);
|
return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
|
});
|
|
mcp_server.AddTool("self.mp3.get_list",
|
"List MP3 files on the SD card. For list/query requests, only return this list. For play, random play, switch song, or named-song requests, use this only to choose an exact files[].name, then call self.mp3.play before replying to the user.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
return std::string(SDCard_Get_MP3_List());
|
});
|
|
static std::string last_mp3_play_name;
|
static float last_mp3_play_speed = 1.0f;
|
static int64_t last_mp3_play_time = 0;
|
mcp_server.AddTool("self.mp3.play",
|
"Play one MP3 file from the SD card. The name argument must exactly match a files[].name from self.mp3.get_list. Optional speed supports only 0.5, 0.75, 1.0, 1.25, 1.5, or 2.0; non-1.0 speeds change pitch and affect only SD card MP3 playback. For play, random play, switch song, or named-song requests, call this tool before telling the user playback has started. Do not call this tool repeatedly for the same user request after it has returned accepted=true.",
|
PropertyList({
|
Property("name", kPropertyTypeString),
|
Property("speed", kPropertyTypeNumber, 1.0)
|
}),
|
[](const PropertyList& properties) -> ReturnValue {
|
std::string name = properties["name"].value<std::string>();
|
float speed = static_cast<float>(properties["speed"].value<double>());
|
int64_t now = esp_timer_get_time();
|
if (name == last_mp3_play_name && std::fabs(speed - last_mp3_play_speed) < 0.001f && now - last_mp3_play_time < 15000000) {
|
return std::string("{\"accepted\":true,\"duplicate_ignored\":true}");
|
}
|
|
esp_err_t err = SDCard_Play_MP3_With_Speed(name.data(), speed);
|
if (err != ESP_OK) {
|
return std::string("{\"accepted\":false,\"error\":\"") + esp_err_to_name(err) +
|
"\",\"retry\":\"use exact files[].name from self.mp3.get_list\"}";
|
}
|
last_mp3_play_name = name;
|
last_mp3_play_speed = speed;
|
last_mp3_play_time = now;
|
return std::string("{\"accepted\":true}");
|
});
|
|
mcp_server.AddTool("self.mp3.get_status",
|
"Get current SD card MP3 playback status. playing means an MP3 track is active; tts_paused only means playback is temporarily muted while the assistant is speaking.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
return std::string("{\"playing\":") + (SDCard_Is_Playing() ? "true" : "false") +
|
",\"tts_paused\":" + (SDCard_Is_Paused() ? "true" : "false") + "}";
|
});
|
|
mcp_server.AddTool("self.mp3.stop",
|
"Stop the current SD card MP3 playback and any active MQTT playlist. Use this when the user asks to pause or stop music.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
SDCard_Stop_Play();
|
return std::string("{\"stopped\":true,\"playing\":") + (SDCard_Is_Playing() ? "true" : "false") +
|
",\"tts_paused\":" + (SDCard_Is_Paused() ? "true" : "false") + "}";
|
});
|
|
mcp_server.AddTool("self.enter_standby",
|
"Request standby mode. You must call this tool, not just say it is done, when the user asks for 待命, 待命状态, 进入待命状态, 待机, 休眠, sleep, wait, stop listening, or stop responding. The device will finish the current assistant reply first, then return to standby.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
Application::GetInstance().EnterStandby();
|
return std::string("{\"standby_requested\":true}");
|
});
|
|
mcp_server.AddTool("self.power_off",
|
"Request power off into alarm-wake standby. Always call this when the user asks to power off, shut down, or enter 关机状态. The device will finish the current assistant reply first, then disconnect WiFi, stop playback, and disable wake word and command word recognition until an RTC alarm restores it.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
Application::GetInstance().RequestPowerOffStandby();
|
return std::string("{\"power_off_requested\":true}");
|
});
|
|
mcp_server.AddTool("self.mp3.delete",
|
"Delete an MP3 file from the SD card by file name from self.mp3.get_list.",
|
PropertyList({
|
Property("name", kPropertyTypeString)
|
}),
|
[](const PropertyList& properties) -> ReturnValue {
|
std::string name = properties["name"].value<std::string>();
|
SDCard_Delete_MP3(name.data());
|
return std::string(SDCard_Get_MP3_List());
|
});
|
|
mcp_server.AddTool("self.jpg.get_list",
|
"List JPG files on the SD card. For list/query requests, only return this list. For display/show/open-image requests, use this only to choose an exact files[].name, then call self.jpg.play before replying to the user.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
return std::string(SDCard_Get_JPG_List());
|
});
|
|
mcp_server.AddTool("self.jpg.play",
|
"Display one JPG file on the screen. The name argument must exactly match a files[].name from self.jpg.get_list. For display/show/open-image requests, call this tool before telling the user the image is shown.",
|
PropertyList({
|
Property("name", kPropertyTypeString)
|
}),
|
[this](const PropertyList& properties) -> ReturnValue {
|
std::string name = properties["name"].value<std::string>();
|
SDCard_Play_JPG(panel_handle, name.data());
|
return std::string("{\"shown\":true}");
|
});
|
|
mcp_server.AddTool("self.jpg.delete",
|
"Delete a JPG file from the SD card by file name from self.jpg.get_list.",
|
PropertyList({
|
Property("name", kPropertyTypeString)
|
}),
|
[](const PropertyList& properties) -> ReturnValue {
|
std::string name = properties["name"].value<std::string>();
|
SDCard_Delete_JPG(name.data());
|
return std::string(SDCard_Get_JPG_List());
|
});
|
|
mcp_server.AddTool("self.battery.get_status",
|
"Get battery level and charging status. Returns JSON with level 0-100 and charging boolean.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
return std::string("{\"level\":") + std::to_string(Battery_Get_Value()) +
|
",\"charging\":" + (Battery_Is_Charging() ? "true" : "false") + "}";
|
});
|
|
mcp_server.AddTool("self.wifi.get_history_list",
|
"Get saved WiFi history as a JSON string. Passwords are not returned.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
return std::string(WIFI_Get_History_List());
|
});
|
|
mcp_server.AddTool("self.wifi.delete_all",
|
"Delete all saved WiFi credentials. The current connection is not disconnected immediately.",
|
PropertyList(),
|
[](const PropertyList& properties) -> ReturnValue {
|
WIFI_Delete_All();
|
return std::string(WIFI_Get_History_List());
|
});
|
}
|
|
public:
|
sening_xiaozhi_s3() : boot_button_(BOOT_BUTTON_GPIO) {
|
InitializeDS2321();
|
InitializeCodecI2c();
|
InitializeButtons();
|
InitializeWifiStandbyTimer();
|
InitializeSpi();
|
InitializeGc9d01Display();
|
InitializeCard();
|
InitializeTools();
|
}
|
|
virtual void SetNetworkEventCallback(NetworkEventCallback callback) override {
|
WifiBoard::SetNetworkEventCallback([this, callback](NetworkEvent event, const std::string& data) {
|
if (event == NetworkEvent::Connected) {
|
wifi_standby_active_ = false;
|
if (wifi_reconnect_waiting_ && wifi_reconnect_event_group_) {
|
xEventGroupSetBits(wifi_reconnect_event_group_, WIFI_RECONNECT_CONNECTED_BIT);
|
}
|
OnWifiConnected(data);
|
} else if (event == NetworkEvent::ConnectionFailed) {
|
if (wifi_reconnect_waiting_ && wifi_reconnect_event_group_) {
|
xEventGroupSetBits(wifi_reconnect_event_group_, WIFI_RECONNECT_FAILED_BIT);
|
}
|
} else if (event == NetworkEvent::WifiConfigModeEnter) {
|
if (!power_off_active_) {
|
ESP_LOGW(TAG, "WiFi config mode entered, stop local MQTT playlist resume");
|
BoardMqtt_Stop();
|
}
|
} else if (event == NetworkEvent::Disconnected) {
|
BoardMqtt_Stop();
|
}
|
|
bool suppress_disconnected = event == NetworkEvent::Disconnected && (wifi_standby_active_ || power_off_active_);
|
if (callback && !suppress_disconnected) {
|
callback(event, data);
|
}
|
});
|
}
|
|
virtual bool EnsureNetworkReadyForWake(int timeout_ms) override {
|
if (wifi_standby_timer_) {
|
wifi_standby_timer_->WakeUp();
|
}
|
auto& wifi_manager = WifiManager::GetInstance();
|
if (wifi_manager.IsConnected()) {
|
wifi_standby_active_ = false;
|
return true;
|
}
|
if (!wifi_reconnect_event_group_) {
|
return false;
|
}
|
|
ESP_LOGI(TAG, "Restoring WiFi for wake");
|
xEventGroupClearBits(wifi_reconnect_event_group_, WIFI_RECONNECT_CONNECTED_BIT | WIFI_RECONNECT_FAILED_BIT);
|
wifi_reconnect_waiting_ = true;
|
SetSuppressConfigModeOnConnectionFailed(true);
|
wifi_manager.StartStation();
|
|
EventBits_t bits = xEventGroupWaitBits(wifi_reconnect_event_group_,
|
WIFI_RECONNECT_CONNECTED_BIT | WIFI_RECONNECT_FAILED_BIT,
|
pdTRUE, pdFALSE, pdMS_TO_TICKS(timeout_ms));
|
|
wifi_reconnect_waiting_ = false;
|
if (bits & WIFI_RECONNECT_CONNECTED_BIT) {
|
SetSuppressConfigModeOnConnectionFailed(false);
|
wifi_standby_active_ = false;
|
return true;
|
}
|
|
ESP_LOGW(TAG, "WiFi restore failed, keep offline standby");
|
wifi_manager.StopStation();
|
SetSuppressConfigModeOnConnectionFailed(false);
|
wifi_standby_active_ = true;
|
return false;
|
}
|
|
virtual bool IsNetworkReady() override {
|
return !wifi_standby_active_ && !power_off_active_ && WifiManager::GetInstance().IsConnected();
|
}
|
|
virtual AudioCodec* GetAudioCodec() override {
|
static Es8311AudioCodec audio_codec(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);
|
return &audio_codec;
|
}
|
|
virtual bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override {
|
level = Battery_Get_Value();
|
charging = Battery_Is_Charging();
|
discharging = !charging;
|
return true;
|
}
|
|
virtual std::string GetDeviceStatusJson() override {
|
std::string base = WifiBoard::GetDeviceStatusJson();
|
cJSON* root = cJSON_Parse(base.c_str());
|
if (!root) {
|
return base;
|
}
|
|
auto mp3 = cJSON_CreateObject();
|
cJSON_AddBoolToObject(mp3, "playing", SDCard_Is_Playing());
|
cJSON_AddBoolToObject(mp3, "tts_paused", SDCard_Is_Paused());
|
cJSON_AddItemToObject(root, "sdcard_mp3", mp3);
|
|
char* json = cJSON_PrintUnformatted(root);
|
std::string result(json ? json : base);
|
if (json) {
|
cJSON_free(json);
|
}
|
cJSON_Delete(root);
|
return result;
|
}
|
};
|
|
extern "C" esp_err_t Board_Enter_Power_Off(void)
|
{
|
auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
|
return board.EnterPowerOffStandby();
|
}
|
|
extern "C" esp_err_t Board_Restore_From_Power_Off(int timeout_ms)
|
{
|
if (timeout_ms < 0) {
|
timeout_ms = 0;
|
}
|
|
auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
|
return board.RestoreFromPowerOffStandby(timeout_ms);
|
}
|
|
extern "C" bool Board_Is_Power_Off(void)
|
{
|
auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
|
return board.IsPowerOffStandby();
|
}
|
|
DECLARE_BOARD(sening_xiaozhi_s3);
|