chore: checkpoint 第一版
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-06 17:04:11 +08:00
parent a8cdec98d1
commit 0c2151ace9
659 changed files with 185855 additions and 554 deletions
@@ -0,0 +1,33 @@
# Copyright (2025) Beijing Volcano Engine Technology Ltd.
# SPDX-License-Identifier: Apache License 2.0
file(GLOB APP_SRC "./echoear_app/*.c")
set(COMPONENT_SRCS "conv_ai_embedded_kit.c configuration_ap.c network.c platform_client.c" )
if (CONFIG_PRINT_TASK_INFO)
add_definitions(-DPRINT_TASK_INFO)
endif()
set(COMPONENT_ADD_INCLUDEDIRS .)
set(COMPONENT_EMBED_TXTFILES assets/wifi_configuration_ap.html)
set(COMPONENT_REQUIRES esp_http_client esp_http_server esp_wifi esp-tls mbedtls nvs_flash json espressif__gmf_ai_audio espressif__esp_board_manager espressif__esp_lcd_touch)
register_component()
set(assets_root "${CMAKE_CURRENT_LIST_DIR}/../assets")
if(EXISTS "${assets_root}")
spiffs_create_partition_assets(eaf "${assets_root}" FLASH_IN_PROJECT
MMAP_FILE_SUPPORT_FORMAT ".eaf"
IMPORT_INC_PATH "${CMAKE_CURRENT_SOURCE_DIR}")
add_dependencies(${COMPONENT_LIB} assets_eaf_bin)
# The application component includes mmap_generate_eaf.h, which is emitted by
# the assets generator into this directory. Make that relationship explicit
# so a clean parallel build cannot compile the display source too early.
idf_component_get_property(application_lib application COMPONENT_LIB)
add_dependencies(${application_lib} assets_eaf_bin)
endif()
set(COMPONENT_ADD_INCLUDEDIRS . ${FRAMEWORK_INC} ${SERVICE_INC})
@@ -0,0 +1,31 @@
menu "Example Configuration"
choice AI_AGENT_MODE
prompt "Volcano RTC/WS mode"
default VOLC_WS_MODE
config VOLC_WS_MODE
bool "enable volcano ws mode"
endchoice
menu "Develop Configuration"
choice VOLC_AUDIO_CODEC
prompt "supported audio codec"
default VOLC_AUDIO_G711A
config VOLC_AUDIO_G711A
bool "External G711A codec: 8k G711A data to SDK for upstream; 8k G711A data callback from SDK for downstream"
config VOLC_AUDIO_PCM16
bool "No audio codec: 16k pcm data to SDK for upstream; 16k pcm data callback from SDK for downstream"
endchoice
config PRINT_TASK_INFO
bool "print task info"
default n
endmenu
endmenu
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>EchoEar 平台激活</title>
<style>body{font:16px system-ui;background:#f1f5f9;margin:0;color:#172b4d}main{max-width:420px;margin:32px auto;padding:24px;background:white;border-radius:16px}label{display:block;margin:18px 0}input,button{box-sizing:border-box;width:100%;padding:12px;border:1px solid #cbd5e1;border-radius:8px;font:inherit}input{margin-top:6px}button{cursor:pointer;margin-top:12px;background:#1769d2;color:white}button:disabled{opacity:.6;cursor:wait}small{color:#526079}#status{white-space:pre-wrap;line-height:1.6}#networks a{display:block;margin:12px 0}</style></head>
<body><main><h1>EchoEar 平台激活</h1><p>填写家中的2.4 GHz Wi-Fi 和平台分配给这台设备的凭据。</p>
<form id="form" novalidate>
<label>2.4 GHz Wi-Fi 名称<input id="ssid" required></label>
<label>Wi-Fi 密码<input id="password" type="password" maxlength="64" autocomplete="off"><small>无密码网络可留空。</small></label>
<label>平台 Device ID<input id="device_id" required minlength="36" maxlength="36" autocomplete="off"></label>
<label>平台 Device Secret<input id="device_secret" type="password" required minlength="32" maxlength="128" autocomplete="off"></label>
<p id="status" role="status" aria-live="polite">请保持手机连接设备热点。</p>
<button id="submit" type="submit">连接并激活</button><button id="scan" type="button">扫描附近的 Wi-Fi</button><div id="networks"></div>
</form></main><script>
const form=document.getElementById('form'), button=document.getElementById('submit'), scan=document.getElementById('scan'), status=document.getElementById('status'), networks=document.getElementById('networks');
let busy=false;
const messages={invalid_fields:'请检查配置:Wi-Fi 名称最多32字节,Device ID 为36位,Device Secret 为32至128位。',wifi_failed:'Wi-Fi 连接失败或超时。请检查2.4 GHz网络名称和密码,再重试。',save_failed:'设备保存配置失败,请重试。',receive_timeout:'设备未完整收到配置,请保持连接热点后重试。',no_memory:'设备暂时忙,请稍后重试。'};
function show(text,failed=false){status.textContent=text;status.style.color=failed?'#b42318':'#175cd3'}
function setBusy(value){busy=value;button.disabled=value;scan.disabled=value;button.textContent=value?'正在连接,请稍候…':'连接并激活'}
async function request(path,options={},timeout=7000){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout);try{const response=await fetch(path,{...options,cache:'no-store',signal:controller.signal}),data=await response.json();if(!response.ok)throw Object.assign(new Error(data.error||'request_failed'),{code:data.error});return data}finally{clearTimeout(timer)}}
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function watchResult(){const deadline=Date.now()+50000;while(Date.now()<deadline){let data;try{data=await request('/setup-status')}catch(_){show('暂时联系不到设备。请保持连接 VolcConvAI 热点,正在重试查询…');await delay(1500);continue}
if(data.state==='saved'){show('配置已保存!设备将在15秒内重启并进行平台激活。热点随后消失属于正常现象,请查看设备屏幕结果。');button.textContent='配置已保存';return}
if(data.state==='wifi_failed'||data.state==='save_failed'){setBusy(false);show(messages[data.state],true);return}
if(data.state==='idle'){setBusy(false);show('设备尚未收到连接任务,请检查配置后重新提交。',true);return}
show('正在连接家中 Wi-Fi,最多等待约30秒。请保持连接设备热点;暂时断开时请重新连接。');await delay(1500)}setBusy(false);show('暂时无法确认结果。若设备已重启,请查看屏幕;若热点仍在,请重新连接热点并刷新此页查询。',true)}
form.addEventListener('submit',async event=>{event.preventDefault();if(busy)return;const id=document.getElementById('device_id');id.value=id.value.trim();if(!form.checkValidity()){show('请完整填写 Wi-Fi 名称、36位 Device ID 和32至128位 Device Secret。',true);form.reportValidity();return}
const data={ssid:document.getElementById('ssid').value,password:document.getElementById('password').value,device_id:id.value,device_secret:document.getElementById('device_secret').value};
if(new TextEncoder().encode(data.ssid).length>32||new TextEncoder().encode(data.password).length>64){show('Wi-Fi 名称不能超过32字节,密码不能超过64字节(中文占多个字节)。',true);return}
setBusy(true);show('正在提交配置…');try{await request('/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})}catch(failure){if(failure.code&&failure.code!=='busy'){setBusy(false);show(messages[failure.code]||'设备拒绝请求,请检查配置后重试。',true);return}show('正在确认设备是否收到配置…')}await watchResult()});
scan.addEventListener('click',async()=>{if(busy)return;scan.disabled=true;button.disabled=true;networks.textContent='正在扫描附近的2.4 GHz Wi-Fi…';try{const data=await request('/scan',{},15000);networks.textContent=data.length?'点击选择网络:':'未找到网络,也可以手动填写。';data.forEach(item=>{const link=document.createElement('a');link.href='#';link.textContent=item.ssid+' ('+item.rssi+' dBm)';link.addEventListener('click',event=>{event.preventDefault();document.getElementById('ssid').value=item.ssid});networks.appendChild(link)})}catch(_){networks.textContent='扫描失败,可以手动填写 Wi-Fi 名称后连接。'}finally{scan.disabled=busy;button.disabled=busy}});
async function restoreStatus(){try{const data=await request('/setup-status');if(busy)return;if(data.state==='connecting'||data.state==='saved'){setBusy(true);await watchResult()}else if(messages[data.state])show(messages[data.state],true)}catch(_){if(!busy)show('请确认手机仍连接设备热点,再刷新此页。',true)}}
restoreStatus();
</script></body></html>
@@ -0,0 +1,20 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#ifndef __COMMON_H__
#define __COMMON_H__
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
const char *key;
const char *value;
} http_req_header_t;
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,381 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#include "configuration_ap.h"
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <esp_err.h>
#include <esp_event.h>
#include <esp_mac.h>
#include <esp_netif.h>
#include <esp_wifi.h>
#include <esp_log.h>
#include <nvs.h>
#include <nvs_flash.h>
#include <lwip/ip_addr.h>
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include "platform_client.h"
#include "cJSON.h"
#include "freertos/task.h"
#define TAG "CONFIGURATION_AP"
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
#define WEB_SERVER_URL "http://192.168.4.1"
extern const char index_html_start[] asm("_binary_wifi_configuration_ap_html_start");
ap_t ap;
typedef enum { SETUP_IDLE, SETUP_CONNECTING, SETUP_SAVED, SETUP_WIFI_FAILED, SETUP_SAVE_FAILED } setup_state_t;
static setup_state_t setup_state;
static portMUX_TYPE setup_mux = portMUX_INITIALIZER_UNLOCKED;
static setup_state_t get_setup_state(void) {
portENTER_CRITICAL(&setup_mux);
setup_state_t state = setup_state;
portEXIT_CRITICAL(&setup_mux);
return state;
}
static void set_setup_state(setup_state_t state) {
portENTER_CRITICAL(&setup_mux);
setup_state = state;
portEXIT_CRITICAL(&setup_mux);
}
static esp_err_t send_json(httpd_req_t *r, const char *status, const char *body) {
httpd_resp_set_status(r, status);
httpd_resp_set_type(r, "application/json; charset=utf-8");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
return httpd_resp_send(r, body, HTTPD_RESP_USE_STRLEN);
}
void start_access_point()
{
// Get the SSID
uint8_t mac[6];
esp_read_mac(mac, ESP_MAC_WIFI_STA);
snprintf(ap.ssid, 32, "%s-%02X%02X%02X", "VolcConvAI", mac[3], mac[4], mac[5]);
// Initialize the TCP/IP stack
ESP_ERROR_CHECK(esp_netif_init());
// Create the default event loop
esp_netif_t *netif = esp_netif_create_default_wifi_ap();
esp_netif_create_default_wifi_sta();
// Set the router IP address to 192.168.4.1
esp_netif_ip_info_t ip_info;
IP4_ADDR(&ip_info.ip, 192, 168, 4, 1);
IP4_ADDR(&ip_info.gw, 192, 168, 4, 1);
IP4_ADDR(&ip_info.netmask, 255, 255, 255, 0);
esp_netif_dhcps_stop(netif);
esp_netif_set_ip_info(netif, &ip_info);
esp_netif_dhcps_start(netif);
// Initialize the WiFi stack in Access Point mode
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
// Set the WiFi configuration
wifi_config_t wifi_config = {};
strncpy((char *)wifi_config.ap.ssid, ap.ssid, sizeof(wifi_config.ap.ssid));
wifi_config.ap.ssid_len = strlen(ap.ssid);
wifi_config.ap.max_connection = 4;
wifi_config.ap.authmode = WIFI_AUTH_WPA2_PSK;
strncpy((char *)wifi_config.ap.password, ap.ssid, sizeof(wifi_config.ap.password) - 1);
wifi_config.ap.password[sizeof(wifi_config.ap.password) - 1] = '\0';
// Start the WiFi Access Point
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "Access Point started with SSID %s", ap.ssid);
}
esp_err_t handler_index_html(httpd_req_t *r)
{
httpd_resp_set_type(r, "text/html; charset=utf-8");
httpd_resp_set_hdr(r, "Cache-Control", "no-store");
httpd_resp_send(r, index_html_start, strlen(index_html_start));
return ESP_OK;
}
esp_err_t handler_scan(httpd_req_t *r)
{
if (get_setup_state() == SETUP_CONNECTING || get_setup_state() == SETUP_SAVED)
return send_json(r, "409 Conflict", "{\"error\":\"busy\"}");
if (esp_wifi_scan_start(NULL, true) != ESP_OK)
return send_json(r, "503 Service Unavailable", "{\"error\":\"scan_failed\"}");
uint16_t count = 0;
esp_wifi_scan_get_ap_num(&count);
if (count > 32) count = 32;
wifi_ap_record_t *records = count ? calloc(count, sizeof(*records)) : NULL;
if (count && !records) {
esp_wifi_clear_ap_list();
return send_json(r, "503 Service Unavailable", "{\"error\":\"no_memory\"}");
}
if (count && esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) {
free(records);
esp_wifi_clear_ap_list();
return send_json(r, "503 Service Unavailable", "{\"error\":\"scan_failed\"}");
}
esp_wifi_clear_ap_list();
cJSON *list = cJSON_CreateArray();
for (int i = 0; list && i < count; i++) {
cJSON *item = cJSON_CreateObject();
if (!item) break;
cJSON_AddStringToObject(item, "ssid", (const char *)records[i].ssid);
cJSON_AddNumberToObject(item, "rssi", records[i].rssi);
cJSON_AddNumberToObject(item, "authmode", records[i].authmode);
cJSON_AddItemToArray(list, item);
}
free(records);
char *body = list ? cJSON_PrintUnformatted(list) : NULL;
cJSON_Delete(list);
esp_err_t ret = body ? send_json(r, "200 OK", body) :
send_json(r, "503 Service Unavailable", "{\"error\":\"no_memory\"}");
free(body);
return ret;
}
bool connect2wifi(char *ssid, char *password, EventGroupHandle_t event_group)
{
xEventGroupClearBits(event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT);
if (esp_wifi_disconnect() == ESP_OK) {
xEventGroupWaitBits(event_group, WIFI_FAIL_BIT, pdTRUE, pdFALSE, pdMS_TO_TICKS(1000));
}
xEventGroupClearBits(event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT);
// auto esp_netif = esp_netif_create_default_wifi_sta();
wifi_config_t wifi_config;
bzero(&wifi_config, sizeof(wifi_config));
strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid));
strncpy((char *)wifi_config.sta.password, password, sizeof(wifi_config.sta.password));
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
wifi_config.sta.failure_retry_cnt = 1;
if (esp_wifi_set_config(WIFI_IF_STA, &wifi_config) != ESP_OK) return false;
esp_err_t ret = esp_wifi_connect();
if (ret != ESP_OK)
{
ESP_LOGE(TAG, "Failed to connect to WiFi: %d", ret);
return false;
}
ESP_LOGI(TAG, "Connecting to WiFi %s", ssid);
// Require an IP address, with a bounded wait so the form can be retried.
EventBits_t bits = xEventGroupWaitBits(event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdTRUE, pdFALSE, pdMS_TO_TICKS(30000));
if (bits & WIFI_CONNECTED_BIT)
{
ESP_LOGI(TAG, "Connected to WiFi %s", ssid);
return true;
}
else
{
ESP_LOGE(TAG, "Failed to connect to WiFi %s", ssid);
return false;
}
}
typedef struct {
char ssid[33];
char password[65];
char device_id[37];
char device_secret[129];
} setup_input_t;
static esp_err_t save_setup(const setup_input_t *input)
{
nvs_handle_t nvs;
esp_err_t ret = nvs_open("wifi", NVS_READWRITE, &nvs);
if (ret != ESP_OK) return ret;
// Keep recovery available if persisting either namespace fails.
ret = nvs_set_u8(nvs, "setup_requested", 1);
if (ret == ESP_OK) ret = nvs_commit(nvs);
if (ret == ESP_OK) ret = platform_identity_save(input->device_id, input->device_secret);
if (ret == ESP_OK) ret = nvs_set_str(nvs, "ssid", input->ssid);
if (ret == ESP_OK) ret = nvs_set_str(nvs, "password", input->password);
if (ret == ESP_OK) ret = nvs_set_u8(nvs, "setup_requested", 0);
if (ret == ESP_OK) ret = nvs_commit(nvs);
nvs_close(nvs);
return ret;
}
static void setup_worker(void *arg)
{
setup_input_t *input = arg;
if (!connect2wifi(input->ssid, input->password, ap.event_group)) {
set_setup_state(SETUP_WIFI_FAILED);
} else {
esp_err_t ret = save_setup(input);
ESP_LOGI(TAG, "Setup save result: %s", esp_err_to_name(ret));
set_setup_state(ret == ESP_OK ? SETUP_SAVED : SETUP_SAVE_FAILED);
}
memset(input, 0, sizeof(*input));
free(input);
if (get_setup_state() == SETUP_SAVED) {
// Leave time for the browser to retrieve the result before AP shutdown.
vTaskDelay(pdMS_TO_TICKS(15000));
esp_restart();
}
vTaskDelete(NULL);
}
static bool read_field(cJSON *json, const char *name, char *out, size_t capacity, size_t min)
{
cJSON *value = cJSON_GetObjectItemCaseSensitive(json, name);
if (!cJSON_IsString(value) || !value->valuestring) return false;
size_t length = strlen(value->valuestring);
if (length < min || length >= capacity) return false;
memcpy(out, value->valuestring, length + 1);
return true;
}
esp_err_t handler_setup_status(httpd_req_t *r)
{
const char *states[] = {"idle", "connecting", "saved", "wifi_failed", "save_failed"};
char body[64];
snprintf(body, sizeof(body), "{\"state\":\"%s\"}", states[get_setup_state()]);
return send_json(r, "200 OK", body);
}
esp_err_t handler_form_submission(httpd_req_t *r)
{
if (get_setup_state() == SETUP_CONNECTING || get_setup_state() == SETUP_SAVED)
return send_json(r, "409 Conflict", "{\"error\":\"busy\"}");
if (r->content_len <= 0 || r->content_len > 1536)
return send_json(r, "400 Bad Request", "{\"error\":\"invalid_fields\"}");
char *body = calloc(1, r->content_len + 1);
if (!body) return send_json(r, "503 Service Unavailable", "{\"error\":\"no_memory\"}");
size_t received = 0;
while (received < r->content_len) {
int count = httpd_req_recv(r, body + received, r->content_len - received);
if (count <= 0) {
memset(body, 0, r->content_len);
free(body);
return send_json(r, "408 Request Timeout", "{\"error\":\"receive_timeout\"}");
}
received += count;
}
cJSON *json = cJSON_Parse(body);
memset(body, 0, r->content_len);
free(body);
setup_input_t *input = calloc(1, sizeof(*input));
bool valid = input && cJSON_IsObject(json) &&
read_field(json, "ssid", input->ssid, sizeof(input->ssid), 1) &&
read_field(json, "password", input->password, sizeof(input->password), 0) &&
read_field(json, "device_id", input->device_id, sizeof(input->device_id), 36) &&
read_field(json, "device_secret", input->device_secret, sizeof(input->device_secret), 32);
cJSON_Delete(json);
if (!valid) {
if (input) { memset(input, 0, sizeof(*input)); free(input); }
ESP_LOGW(TAG, "Setup submission rejected: invalid fields");
return send_json(r, "400 Bad Request", "{\"error\":\"invalid_fields\"}");
}
set_setup_state(SETUP_CONNECTING);
if (xTaskCreate(setup_worker, "setup_connect", 6144, input, 5, NULL) != pdPASS) {
memset(input, 0, sizeof(*input));
free(input);
set_setup_state(SETUP_IDLE);
return send_json(r, "503 Service Unavailable", "{\"error\":\"no_memory\"}");
}
ESP_LOGI(TAG, "Setup submission accepted; connecting in background");
return send_json(r, "202 Accepted", "{\"state\":\"connecting\"}");
}
void start_web_server()
{
// Start the web server
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard;
ESP_ERROR_CHECK(httpd_start(&(ap.server), &config));
// Register the index.html file
httpd_uri_t index_html = {
.uri = "/",
.method = HTTP_GET,
.handler = handler_index_html,
.user_ctx = NULL};
ESP_ERROR_CHECK(httpd_register_uri_handler(ap.server, &index_html));
// Register the /scan URI
httpd_uri_t scan = {
.uri = "/scan",
.method = HTTP_GET,
.handler = handler_scan,
.user_ctx = NULL};
ESP_ERROR_CHECK(httpd_register_uri_handler(ap.server, &scan));
// Register the form submission
httpd_uri_t form_submit = {
.uri = "/submit",
.method = HTTP_POST,
.handler = handler_form_submission,
.user_ctx = NULL};
ESP_ERROR_CHECK(httpd_register_uri_handler(ap.server, &form_submit));
httpd_uri_t status = { .uri = "/setup-status", .method = HTTP_GET,
.handler = handler_setup_status };
ESP_ERROR_CHECK(httpd_register_uri_handler(ap.server, &status));
ESP_LOGI(TAG, "Web server started");
}
void handler_event_wifi_ap(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
if (event_id == WIFI_EVENT_AP_STACONNECTED)
{
wifi_event_ap_staconnected_t *event = (wifi_event_ap_staconnected_t *)event_data;
ESP_LOGI(TAG, "Station " MACSTR " joined, AID=%d", MAC2STR(event->mac), event->aid);
}
else if (event_id == WIFI_EVENT_AP_STADISCONNECTED)
{
wifi_event_ap_stadisconnected_t *event = (wifi_event_ap_stadisconnected_t *)event_data;
ESP_LOGI(TAG, "Station " MACSTR " left, AID=%d", MAC2STR(event->mac), event->aid);
}
else if (event_id == WIFI_EVENT_STA_CONNECTED)
{
// Association alone does not mean DHCP has completed.
}
else if (event_id == WIFI_EVENT_STA_DISCONNECTED)
{
xEventGroupSetBits(ap.event_group, WIFI_FAIL_BIT);
}
}
void handler_event_ip_ap(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
if (event_id == IP_EVENT_STA_GOT_IP)
{
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Got IP:" IPSTR, IP2STR(&event->ip_info.ip));
xEventGroupSetBits(ap.event_group, WIFI_CONNECTED_BIT);
}
}
void configure_ap()
{
ap.ssid[0] = '\0';
ap.event_group = xEventGroupCreate();
// Register event handlers
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&handler_event_wifi_ap,
NULL,
&(ap.instance_any_id)));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
&handler_event_ip_ap,
NULL,
&(ap.instance_got_ip)));
start_access_point();
start_web_server();
ESP_LOGI(TAG, "Setup portal ready at " WEB_SERVER_URL);
}
@@ -0,0 +1,15 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#include <esp_http_server.h>
typedef struct
{
httpd_handle_t server;
EventGroupHandle_t event_group;
esp_event_handler_instance_t instance_any_id;
esp_event_handler_instance_t instance_got_ip;
char ssid[32];
} ap_t;
void configure_ap();
@@ -0,0 +1,381 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include <time.h>
#include <inttypes.h>
#include "esp_board_manager.h"
#include "dev_lcd_touch_i2c.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_system.h"
#include "nvs_flash.h"
#include "esp_netif.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_heap_task_info.h"
#include "esp_random.h"
#include "esp_sntp.h"
#include "freertos/semphr.h"
#include "esp_err.h"
#include "sdkconfig.h"
// #include "periph_wifi.h"
// #include "fatfs_stream.h"
#include "cJSON.h"
#include "network.h"
#include "volc_manager_service.h"
#include "volc_service_common.h"
#include "volc_conv_service.h"
#include "local_logic_service.h"
#include "volc_function_call_service.h"
#include "volc_hal.h"
#include "volc_hal_display.h"
#include "volc_hal_capture.h"
#include "volc_lvgl_source.h"
// #include "board.h"
#include "esp_mac.h"
#include "esp_gmf_afe.h"
#include "mmap_generate_eaf.h"
#include "platform_client.h"
#include "hal/usb_serial_jtag_ll.h"
#define STATS_TASK_PRIO 5
static const char *TAG = "VolcConvAI";
static volatile bool is_interrupt = false;
static volatile bool conversation_service_ready = false;
extern void conv_ai_service_diagnostic_say(void);
/* A local USB diagnostic command lets service tests run without repeated taps.
* It does not run automatically or accept commands over the network. */
static void usb_diagnostic_task(void *arg)
{
char command[32];
size_t length = 0;
while (true) {
while (usb_serial_jtag_ll_rxfifo_data_available()) {
uint8_t ch;
if (!usb_serial_jtag_ll_read_rxfifo(&ch, 1)) break;
if (ch == '\n' || ch == '\r') {
command[length] = 0;
if (conversation_service_ready && !strcmp(command, "diag-say")) {
ESP_LOGI(TAG, "USB diagnostic: requesting fixed spoken sentence");
conv_ai_service_diagnostic_say();
}
length = 0;
} else if (length < sizeof(command) - 1) {
command[length++] = ch;
} else {
length = 0;
}
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
void session_init(){
volc_service_manager_init();
volc_conv_service_manager_init();
function_call_service_init();
local_logic_service_init();
}
void Task(void* data){
aios_init(VOLC_SERVICE_EVENT_MAX); // AIOS初始化
session_init();
conversation_service_ready = true;
xTaskCreate(usb_diagnostic_task, "usb_diagnostic", 3072, NULL, 3, NULL);
aios_run(); // AIOS启动
return;
}
static void sys_monitor_task(void *pvParameters)
{
static char run_info[1024] = {0};
while (1)
{
// vTaskGetRunTimeStats(run_info);
vTaskList(run_info);
printf("Task Runtime Stats:\n%s", run_info);
printf("--------------------------------\n");
// ESP_LOGI(TAG, "Task Runtime Stats:\n%s", run_info);
// ESP_LOGI(TAG, "--------------------------------\n");
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
void initialize_sntp(void)
{
ESP_LOGI(TAG, "Initializing SNTP");
// 设置SNTP操作模式为轮询(客户端)
esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
// 设置NTP服务器(可以设置多个备用)
esp_sntp_setservername(0, "pool.ntp.org"); // 主服务器
esp_sntp_setservername(1, "cn.pool.ntp.org"); // 备用服务器:中国区
esp_sntp_setservername(2, "ntp1.aliyun.com"); // 备用服务器:阿里云
// 初始化SNTP服务
esp_sntp_init();
// 设置时区(例如北京时间CST-8)
setenv("TZ", "CST-8", 1);
tzset();
}
void wait_for_time_sync(void)
{
ESP_LOGI(TAG, "Waiting for time synchronization...");
// 检查时间是否已同步(1970年之后)
time_t now = 0;
struct tm timeinfo = {0};
int retry_count = 5; // 最大重试次数
while (timeinfo.tm_year < (2024 - 1900) && retry_count > 0)
{
ESP_LOGI(TAG, "Time not set yet, retrying... (%d)", retry_count);
vTaskDelay(1000 / portTICK_PERIOD_MS);
time(&now);
localtime_r(&now, &timeinfo);
retry_count--;
}
if (retry_count > 0)
{
// print_current_time();
}
else
{
ESP_LOGE(TAG, "Failed to synchronize time within timeout period");
}
}
static void rec_engine_cb(void *event, void *user_data)
{
esp_gmf_afe_evt_t * e = (esp_gmf_afe_evt_t *)event;
if (ESP_GMF_AFE_EVT_WAKEUP_START == e->type) {
if (conversation_service_ready) {
ESP_LOGI(TAG, "Wake word detected; requesting conversation");
volc_hal_context_t *ctx = volc_get_global_hal_context();
if (ctx && ctx->display_handle) {
int index = MMAP_EAF_EMOTION_BLINK_FAST_284_126_EAF;
volc_hal_display_set_content(ctx->display_handle, VOLC_DISPLAY_OBJ_MAIN,
VOLC_DISPLAY_IMAGE, &index);
}
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION,NULL,NULL);
}
}
return;
}
static void wifi_setup_touch_task(void *arg)
{
dev_lcd_touch_i2c_handles_t *touch = arg;
TickType_t pressed_at = 0;
TickType_t last_conversation_request = 0;
uint16_t pressed_x = 0;
uint16_t pressed_y = 0;
bool holding = false;
bool setup_triggered = false;
for (;;) {
uint16_t x = 0, y = 0;
uint8_t count = 0;
bool pressed = esp_lcd_touch_read_data(touch->touch_handle) == ESP_OK &&
esp_lcd_touch_get_coordinates(touch->touch_handle, &x, &y, NULL, &count, 1) &&
count > 0;
if (!pressed) {
if (holding && !setup_triggered) {
TickType_t held_for = xTaskGetTickCount() - pressed_at;
ESP_LOGI(TAG, "Touch released at (%u,%u) after %u ms",
pressed_x, pressed_y, (unsigned)(held_for * portTICK_PERIOD_MS));
if (held_for >= pdMS_TO_TICKS(50) &&
held_for < pdMS_TO_TICKS(1500) && conversation_service_ready) {
TickType_t now = xTaskGetTickCount();
if (last_conversation_request != 0 &&
now - last_conversation_request < pdMS_TO_TICKS(5000)) {
ESP_LOGI(TAG, "Ignoring repeated conversation tap");
holding = false;
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
last_conversation_request = now;
ESP_LOGI(TAG, "Screen tapped; requesting conversation");
volc_hal_context_t *ctx = volc_get_global_hal_context();
if (ctx && ctx->display_handle) {
int index = MMAP_EAF_EMOTION_BLINK_FAST_284_126_EAF;
volc_hal_display_set_content(ctx->display_handle, VOLC_DISPLAY_OBJ_MAIN,
VOLC_DISPLAY_IMAGE, &index);
}
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION, NULL, NULL);
}
}
holding = false;
setup_triggered = false;
} else if (!holding) {
holding = true;
pressed_at = xTaskGetTickCount();
pressed_x = x;
pressed_y = y;
} else if (!setup_triggered && pressed_y >= 280 && pressed_y <= 335 &&
pressed_x >= 60 && pressed_x <= 300 &&
(TickType_t)(xTaskGetTickCount() - pressed_at) >= pdMS_TO_TICKS(3000)) {
nvs_handle_t nvs;
esp_err_t ret = nvs_open("wifi", NVS_READWRITE, &nvs);
if (ret == ESP_OK) {
ret = nvs_set_u8(nvs, "setup_requested", 1);
if (ret == ESP_OK) ret = nvs_commit(nvs);
nvs_close(nvs);
}
if (ret == ESP_OK) {
ESP_LOGI(TAG, "Restarting into setup; device identity and Wi-Fi preserved");
setup_triggered = true;
esp_restart();
}
ESP_LOGE(TAG, "Cannot request setup: %s", esp_err_to_name(ret));
holding = false;
}
vTaskDelay(pdMS_TO_TICKS(50));
}
}
static void wifi_setup_button_init(void)
{
dev_lcd_touch_i2c_handles_t *touch = NULL;
esp_err_t ret = esp_board_manager_get_device_handle("lcd_touch", (void **)&touch);
if (ret != ESP_OK || !touch || !touch->touch_handle) {
ESP_LOGE(TAG, "Wi-Fi setup touch input unavailable");
return;
}
if (xTaskCreate(wifi_setup_touch_task, "wifi_setup", 4096, touch, 5, NULL) != pdPASS) {
ESP_LOGE(TAG, "Cannot start Wi-Fi setup touch task");
}
}
void hal_level_init()
{
volc_hal_context_t* g_hal_context = volc_get_global_hal_context();
volc_hal_capture_config_t config = {0};
config.media_type = VOLC_MEDIA_TYPE_AUDIO;
config.data_cb = NULL;
config.user_data = g_hal_context;
config.audio_wakeup_cb = (volc_hal_audio_wakeup_cb_t)rec_engine_cb;
volc_hal_capture_t capture = volc_hal_capture_create(&config);
volc_hal_capture_start(capture,VOLC_AUDIO_MODE_WAKEUP);
// Allow other core to finish initialization
// vTaskDelay(pdMS_TO_TICKS(2000));
}
static void application_boot(void)
{
// xTaskCreate(&sys_monitor_task, "sys_monitor_task", 4096, NULL, 5, NULL);
volc_hal_init();
volc_hal_display_config_t display_config = {0};
volc_hal_display_create(&display_config);
volc_hal_context_t* g_hal_context = volc_get_global_hal_context();
if(g_hal_context == NULL){
return;
}
volc_hal_display_t global_display = g_hal_context->display_handle;
/* Initialize the default event loop */
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_log_level_set("*", ESP_LOG_WARN);
esp_log_level_set("*", ESP_LOG_INFO);
// /* Initialize NVS flash for WiFi configuration */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND)
{
ESP_LOGW(TAG, "Erasing NVS flash to fix corruption");
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
ESP_ERROR_CHECK(platform_client_init());
ESP_ERROR_CHECK(esp_netif_init());
wifi_setup_button_init();
uint8_t station_mac[6] = {0};
char setup_status[96] = "等待配网";
if (esp_read_mac(station_mac, ESP_MAC_WIFI_STA) == ESP_OK) {
snprintf(setup_status, sizeof(setup_status),
"WiFi: VolcConvAI-%02X%02X%02X",
station_mac[3], station_mac[4], station_mac[5]);
}
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS,
VOLC_DISPLAY_TEXT, setup_status);
bool connected = configure_network();
if (connected == false)
{
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS,
VOLC_DISPLAY_TEXT, setup_status);
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_SUBTITLE,
VOLC_DISPLAY_TEXT, "192.168.4.1 密码同热点名");
ESP_LOGI(TAG, "Waiting for setup through the Wi-Fi portal");
return;
}
initialize_sntp();
wait_for_time_sync();
if (!platform_identity_is_configured()) {
ESP_LOGE(TAG, "Platform identity is missing; erase Wi-Fi NVS or use the setup portal");
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT,
"请通过配网页填写平台设备凭据");
return;
}
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT,
"网络已连接,正在激活");
ret = platform_provision_volc_credential();
ESP_LOGI(TAG, "Startup stack headroom after activation: %u bytes", (unsigned)uxTaskGetStackHighWaterMark(NULL));
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Platform provider provisioning failed: %s", esp_err_to_name(ret));
const char *code = platform_last_error_code();
const char *message = "平台激活失败,请检查配置";
if (strcmp(code, "DEVICE_ACTIVATION_REQUIRED") == 0) message = "请先在平台绑定家庭和长者";
else if (strcmp(code, "HARDWARE_AGENT_BINDING_REQUIRED") == 0) message = "请在平台配置硬件智能体";
else if (strcmp(code, "HARDWARE_AGENT_PRODUCT_MISMATCH") == 0) message = "平台产品配置不一致";
else if (strcmp(code, "HARDWARE_AGENT_HARDWARE_MISMATCH") == 0) message = "设备凭据与本机不匹配";
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT, message);
return;
}
hal_level_init();
ESP_LOGI(TAG, "Startup stack headroom after audio: %u bytes", (unsigned)uxTaskGetStackHighWaterMark(NULL));
xTaskCreate(&Task, "aios_task", 8192, NULL, 5, NULL);
int index = MMAP_EAF_HAPPY_EAF;
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_MAIN,VOLC_DISPLAY_IMAGE,&index);
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"轻触屏幕或说嗨,乐鑫");
}
/* Network signing/TLS and audio startup exceed the default 4 KiB main stack.
* Keep this memory available only during startup and free it on every exit. */
static void application_boot_task(void *arg)
{
application_boot();
ESP_LOGI(TAG, "Startup task finished; stack headroom: %u bytes",
(unsigned)uxTaskGetStackHighWaterMark(NULL));
vTaskDelete(NULL);
}
void app_main(void)
{
if (xTaskCreate(application_boot_task, "platform_boot", 24 * 1024,
NULL, 5, NULL) != pdPASS) {
ESP_LOGE(TAG, "Cannot allocate platform startup task");
}
}
@@ -0,0 +1,69 @@
## IDF Component Manager Manifest File
dependencies:
## Required IDF version
idf:
version: '>=5.5.0'
# espressif/esp_board_manager: ^0.4.0
# # Put list of dependencies here
# # For components maintained by Espressif:
# component: "~1.0.0"
# # For 3rd party components:
# username/component: ">=1.0.0,<2.0.0"
# username2/component2:
# version: "~1.0.0"
# # For transient dependencies `public` flag can be set.
# # `public` flag doesn't have an effect dependencies of the `main` component.
# # All dependencies of `main` are public by default.
# public: true
volc_conv_ai:
path: ../components/volc_conv_ai
public: true
application:
path: ../components/application
public: true
espressif/button: =4.*
espressif/esp_hosted:
version: ~1.1
rules:
- if: target in [esp32p4]
espressif/esp_wifi_remote:
matches:
- if: idf_version <=5.4.0 && target in [esp32p4]
version: ~0.4
- if: idf_version >5.4.0 && target in [esp32p4]
version: ~0.6
lvgl/lvgl: 9.2.*
espressif/esp_lcd_st77916: ^1.0.1
espressif/esp_lcd_touch_cst816s:
version: ^1.1.0
public: true
# Keep the versions used by the upstream EchoEar example. Newer releases
# changed LVGL color-format and board-manager Kconfig symbols.
espressif/esp_lvgl_port:
version: "2.6.0"
public: true
espressif/zlib: '*'
espressif2022/esp_emote_gfx:
version: "^1.2.0~1"
public: true
espressif2022/esp_vocat_base_control: ^1.0.0
espressif/esp_mmap_assets:
version: "*"
public: true
gmf_core:
override_path: ../components/esp-gmf/gmf_core
gmf_audio:
override_path: ../components/esp-gmf/elements/gmf_audio
esp_board_manager:
version: "0.4.0"
public: true
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022-2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @file
* @brief This file was generated by esp_mmap_assets, don't modify it
*/
#pragma once
#include "esp_mmap_assets.h"
#define MMAP_EAF_FILES 7
#define MMAP_EAF_CHECKSUM 0x0B63
enum MMAP_EAF_LISTS {
MMAP_EAF_HAPPY_EAF = 0, /*!< Happy.eaf */
MMAP_EAF_BOOT_360_360_EAF = 1, /*!< boot_360_360.eaf */
MMAP_EAF_EMOTION_ANGRY_284_126_EAF = 2, /*!< emotion_angry_284_126.eaf */
MMAP_EAF_EMOTION_BLINK1_284_126_EAF = 3, /*!< emotion_blink1_284_126.eaf */
MMAP_EAF_EMOTION_BLINK_FAST_284_126_EAF = 4, /*!< emotion_blink_fast_284_126.eaf */
MMAP_EAF_EMOTION_DIZZY_284_126_EAF = 5, /*!< emotion_dizzy_284_126.eaf */
MMAP_EAF_EMOTION_SLEEP_284_126_EAF = 6, /*!< emotion_sleep_284_126.eaf */
};
@@ -0,0 +1,214 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#include "network.h"
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <esp_err.h>
#include <esp_event.h>
#include <esp_wifi.h>
#include <esp_log.h>
#include <nvs.h>
#include <nvs_flash.h>
#include "configuration_ap.h"
#include "platform_client.h"
#define TAG "NETWORK"
#define WIFI_EVENT_CONNECTED BIT0
#define WIFI_EVENT_FAILED BIT1
#define WIFI_EVENT_STARTED BIT2
#define MAX_RECONNECT_COUNT 5
static network_t network;
int8_t get_rssi()
{
// Get station info
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK) return 0;
return ap_info.rssi;
}
uint8_t get_channel()
{
// Get station info
wifi_ap_record_t ap_info;
ESP_ERROR_CHECK(esp_wifi_sta_get_ap_info(&ap_info));
return ap_info.primary;
}
void handler_event_wifi_st(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
if (event_id == WIFI_EVENT_STA_START)
{
xEventGroupSetBits(network.event_group, WIFI_EVENT_STARTED);
}
else if (event_id == WIFI_EVENT_STA_DISCONNECTED)
{
xEventGroupClearBits(network.event_group, WIFI_EVENT_CONNECTED);
if (network.reconnect_count < MAX_RECONNECT_COUNT)
{
/* Retry the selected AP first, but allow another AP if it is no longer
* reachable. Never permanently bind this device to one mesh node. */
if (network.reconnect_count == 2) {
wifi_config_t config;
if (esp_wifi_get_config(WIFI_IF_STA, &config) == ESP_OK && config.sta.bssid_set) {
config.sta.bssid_set = false;
config.sta.channel = 0;
esp_wifi_set_config(WIFI_IF_STA, &config);
ESP_LOGW(TAG, "Preferred AP unavailable; falling back to SSID scan");
}
}
esp_wifi_connect();
network.reconnect_count++;
ESP_LOGI(TAG, "Reconnecting WiFi (attempt %d)", network.reconnect_count);
}
else
{
xEventGroupSetBits(network.event_group, WIFI_EVENT_FAILED);
ESP_LOGI(TAG, "WiFi connection failed");
}
}
}
void handler_event_ip_st(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
network.reconnect_count = 0;
esp_ip4addr_ntoa(&event->ip_info.ip, network.ip_address, sizeof(network.ip_address));
ESP_LOGI(TAG, "Got IP: %s", network.ip_address);
xEventGroupSetBits(network.event_group, WIFI_EVENT_CONNECTED);
}
bool is_connected()
{
return xEventGroupGetBits(network.event_group) & WIFI_EVENT_CONNECTED;
}
bool configure_network()
{
// Create the event group
network.ssid[0] = '\0';
network.password[0] = '\0';
network.reconnect_count = 0;
network.ip_address[0] = '\0';
network.event_group = xEventGroupCreate();
// Get ssid and password from NVS
nvs_handle_t nvs_handle;
uint8_t setup_requested = 0;
esp_err_t ret = nvs_open("wifi", NVS_READONLY, &nvs_handle);
if (ret == ESP_OK)
{
nvs_get_u8(nvs_handle, "setup_requested", &setup_requested);
size_t length = sizeof(network.ssid);
nvs_get_str(nvs_handle, "ssid", network.ssid, &length);
length = sizeof(network.password);
nvs_get_str(nvs_handle, "password", network.password, &length);
nvs_close(nvs_handle);
}
if (setup_requested || !platform_identity_is_configured())
{
ESP_LOGI(TAG, "Starting setup: requested or platform identity missing");
configure_ap();
return false;
}
// Try to connect to WiFi, if failed, launch the WiFi configuration AP
if (network.ssid[0] != '\0')
{
ESP_LOGI(TAG, "Connecting to WiFi %s", network.ssid);
// Initialize the TCP/IP stack
ESP_ERROR_CHECK(esp_netif_init());
esp_event_handler_instance_t instance_any_id;
esp_event_handler_instance_t instance_got_ip;
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&handler_event_wifi_st,
NULL,
&instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
&handler_event_ip_st,
NULL,
&instance_got_ip));
// Create the default event loop
esp_netif_t *station_netif = esp_netif_create_default_wifi_sta();
// Initialize the WiFi stack in station mode
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
ESP_LOGI(TAG, "Connecting to WiFi ssid=%s password=***", network.ssid);
wifi_config_t wifi_config;
bzero(&wifi_config, sizeof(wifi_config));
strncpy((char *)wifi_config.sta.ssid, network.ssid, sizeof(wifi_config.sta.ssid));
strncpy((char *)wifi_config.sta.password, network.password, sizeof(wifi_config.sta.password));
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifi_config.sta.threshold.authmode = WIFI_AUTH_OPEN;
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
// Start the WiFi stack
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
xEventGroupWaitBits(network.event_group, WIFI_EVENT_STARTED, pdFALSE, pdFALSE, pdMS_TO_TICKS(5000));
wifi_scan_config_t scan = {.ssid = (uint8_t *)network.ssid};
if (esp_wifi_scan_start(&scan, true) == ESP_OK) {
wifi_ap_record_t records[16];
uint16_t count = 16;
if (esp_wifi_scan_get_ap_records(&count, records) == ESP_OK && count > 0) {
uint16_t best = 0;
for (uint16_t i = 1; i < count; ++i) {
if (records[i].rssi > records[best].rssi) best = i;
}
wifi_config.sta.bssid_set = true;
memcpy(wifi_config.sta.bssid, records[best].bssid, sizeof(wifi_config.sta.bssid));
wifi_config.sta.channel = records[best].primary;
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_LOGI(TAG, "Selected strongest AP among %u: rssi=%d channel=%u bssid=%02x:%02x:%02x:%02x:%02x:%02x",
count, records[best].rssi, records[best].primary,
records[best].bssid[0], records[best].bssid[1], records[best].bssid[2],
records[best].bssid[3], records[best].bssid[4], records[best].bssid[5]);
}
}
ESP_ERROR_CHECK(esp_wifi_connect());
// Wait for the WiFi stack to start
EventBits_t bits = xEventGroupWaitBits(network.event_group, WIFI_EVENT_CONNECTED | WIFI_EVENT_FAILED, pdFALSE, pdFALSE, pdMS_TO_TICKS(30 * 1000));
if (!(bits & WIFI_EVENT_CONNECTED))
{
ESP_LOGE(TAG, "WifiStation failed");
// Reset the WiFi stack
// 取消注册事件处理程序
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, instance_got_ip));
ESP_ERROR_CHECK(esp_wifi_stop());
ESP_ERROR_CHECK(esp_wifi_deinit());
esp_netif_destroy_default_wifi(station_netif);
xEventGroupClearBits(network.event_group, WIFI_EVENT_CONNECTED | WIFI_EVENT_FAILED);
// nvs_flash_erase();
// return false;
} else {
ESP_LOGI(TAG, "Connected to %s rssi=%d channel=%d", network.ssid, get_rssi(), get_channel());
}
}
if (!is_connected())
{
configure_ap();
ESP_LOGI(TAG, "WiFi not connected, starting AP");
return false;
}
return true;
}
@@ -0,0 +1,17 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <esp_http_server.h>
typedef struct
{
EventGroupHandle_t event_group;
char ssid[32];
char password[64];
int reconnect_count;
char ip_address[16];
} network_t;
bool configure_network();
@@ -0,0 +1,301 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#include "pipeline.h"
#include <string.h>
#include "esp_log.h"
#include "sdkconfig.h"
#include "audio_event_iface.h"
#include "audio_common.h"
#include "audio_sys.h"
#include "board.h"
#include "algorithm_stream.h"
#include "filter_resample.h"
#include "i2s_stream.h"
#include "pthread.h"
#if (CONFIG_ESP32_S3_KORVO2_V3_BOARD || CONFIG_ESP32_S3_ECHOEAR_V1_2_BOARD)
#include "es7210.h"
#elif CONFIG_M5STACK_ATOMS3R_BOARD
#include "es8311.h"
#endif
#include "esp_timer.h"
#if defined(CONFIG_VOLC_AUDIO_G711A)
#include "g711_encoder.h"
#include "g711_decoder.h"
#endif
#include "audio_idf_version.h"
#include "raw_stream.h"
#define CHANNEL 1
static const char *TAG = "AUDIO_PIPELINE";
#define I2S_SAMPLE_RATE 16000
#define ALGO_SAMPLE_RATE 16000
#if (CONFIG_ESP32_S3_KORVO2_V3_BOARD || CONFIG_ESP32_S3_ECHOEAR_V1_2_BOARD)
#define ALGORITHM_STREAM_SAMPLE_BIT 32
#define CHANNEL_FORMAT I2S_CHANNEL_TYPE_ONLY_LEFT
#define ALGORITHM_INPUT_FORMAT "RM"
#define CHANNEL_NUM 1
#elif CONFIG_M5STACK_ATOMS3R_BOARD
#define ALGORITHM_STREAM_SAMPLE_BIT 16
#define CHANNEL_FORMAT I2S_CHANNEL_TYPE_RIGHT_LEFT
#define ALGORITHM_INPUT_FORMAT "MR"
#define CHANNEL_NUM 2
#endif
#if (CONFIG_VOLC_AUDIO_G711A)
#define CODEC_NAME "g711a"
#define CODEC_SAMPLE_RATE 8000
#else
#define CODEC_NAME "pcm"
#define CODEC_SAMPLE_RATE 16000
#endif
static audio_element_handle_t create_resample_stream(int src_rate, int src_ch, int dest_rate, int dest_ch)
{
rsp_filter_cfg_t rsp_cfg = DEFAULT_RESAMPLE_FILTER_CONFIG();
rsp_cfg.src_rate = src_rate;
rsp_cfg.src_ch = src_ch;
rsp_cfg.dest_rate = dest_rate;
rsp_cfg.dest_ch = dest_ch;
rsp_cfg.complexity = 5;
audio_element_handle_t stream = rsp_filter_init(&rsp_cfg);
return stream;
}
static audio_element_handle_t create_record_i2s_stream(void)
{
#if (CONFIG_ESP32_S3_KORVO2_V3_BOARD || CONFIG_ESP32_S3_ECHOEAR_V1_2_BOARD)
es7210_adc_set_gain(ES7210_INPUT_MIC3, GAIN_30DB);
#elif CONFIG_M5STACK_ATOMS3R_BOARD
es8311_set_mic_gain(ES8311_MIC_GAIN_36DB);
#endif
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT_WITH_PARA(CODEC_ADC_I2S_PORT, I2S_SAMPLE_RATE, ALGORITHM_STREAM_SAMPLE_BIT, AUDIO_STREAM_READER); // 参数需要仔细检查
i2s_cfg.type = AUDIO_STREAM_READER;
i2s_stream_set_channel_type(&i2s_cfg, CHANNEL_FORMAT);
i2s_cfg.std_cfg.clk_cfg.sample_rate_hz = I2S_SAMPLE_RATE;
return i2s_stream_init(&i2s_cfg);
}
static audio_element_handle_t create_record_encoder_stream(void)
{
#if(CONFIG_VOLC_AUDIO_G711A)
g711_encoder_cfg_t g711_cfg = DEFAULT_G711_ENCODER_CONFIG();
return g711_encoder_init(&g711_cfg);
#else
return NULL;
#endif
}
static audio_element_handle_t create_record_raw_stream(void)
{
audio_element_handle_t raw_stream = NULL;
raw_stream_cfg_t raw_cfg = RAW_STREAM_CFG_DEFAULT();
raw_cfg.type = AUDIO_STREAM_WRITER;
raw_cfg.out_rb_size = 2 * 1024;
raw_stream = raw_stream_init(&raw_cfg);
audio_element_set_output_timeout(raw_stream, portMAX_DELAY);
return raw_stream;
}
static audio_element_handle_t create_record_algo_stream(void)
{
ESP_LOGI(TAG, "[3.1] Create algorithm stream for aec");
algorithm_stream_cfg_t algo_config = ALGORITHM_STREAM_CFG_DEFAULT();
algo_config.sample_rate = ALGO_SAMPLE_RATE;
algo_config.out_rb_size = 256;
algo_config.algo_mask = ALGORITHM_STREAM_DEFAULT_MASK | ALGORITHM_STREAM_USE_AGC;
algo_config.input_format = ALGORITHM_INPUT_FORMAT;
audio_element_handle_t element_algo = algo_stream_init(&algo_config);
audio_element_set_music_info(element_algo, ALGO_SAMPLE_RATE, 1, 16);
audio_element_set_input_timeout(element_algo, portMAX_DELAY);
return element_algo;
}
recorder_pipeline_handle_t recorder_pipeline_open()
{
recorder_pipeline_handle_t pipeline = heap_caps_calloc(1, sizeof(recorder_pipeline_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_DEFAULT);
esp_log_level_set(TAG, ESP_LOG_INFO);
// create and register streams
audio_pipeline_cfg_t pipeline_cfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
pipeline->audio_pipeline = audio_pipeline_init(&pipeline_cfg);
mem_assert(pipeline->audio_pipeline);
pipeline->i2s_stream_reader = create_record_i2s_stream();
audio_pipeline_register(pipeline->audio_pipeline, pipeline->i2s_stream_reader, "i2s");
pipeline->algo_aec = create_record_algo_stream();
audio_pipeline_register(pipeline->audio_pipeline, pipeline->algo_aec, "algo");
pipeline->rsp = create_resample_stream(I2S_SAMPLE_RATE, 1, CODEC_SAMPLE_RATE, 1);
audio_pipeline_register(pipeline->audio_pipeline, pipeline->rsp, "rsp");
pipeline->audio_encoder = create_record_encoder_stream();
if (pipeline->audio_encoder) {
audio_pipeline_register(pipeline->audio_pipeline, pipeline->audio_encoder, CODEC_NAME);
}
pipeline->raw_reader = create_record_raw_stream();
audio_pipeline_register(pipeline->audio_pipeline, pipeline->raw_reader, "raw");
#if (CONFIG_VOLC_AUDIO_G711A)
const char *link_tag[] = {"i2s", "algo", "rsp", CODEC_NAME, "raw"};
#else
const char *link_tag[] = {"i2s", "algo", "raw"};
#endif
audio_pipeline_link(pipeline->audio_pipeline, &link_tag[0], sizeof(link_tag) / sizeof(link_tag[0]));
return pipeline;
}
void recorder_pipeline_close(recorder_pipeline_handle_t pipeline)
{
audio_pipeline_stop(pipeline->audio_pipeline);
audio_pipeline_wait_for_stop(pipeline->audio_pipeline);
audio_pipeline_terminate(pipeline->audio_pipeline);
if (pipeline->i2s_stream_reader)
{
audio_pipeline_unregister(pipeline->audio_pipeline, pipeline->i2s_stream_reader);
audio_element_deinit(pipeline->i2s_stream_reader);
}
if (pipeline->audio_encoder) {
audio_pipeline_unregister(pipeline->audio_pipeline, pipeline->audio_encoder);
audio_element_deinit(pipeline->audio_encoder);
}
if (pipeline->raw_reader)
{
audio_pipeline_unregister(pipeline->audio_pipeline, pipeline->raw_reader);
audio_element_deinit(pipeline->raw_reader);
}
if (pipeline->rsp)
{
audio_pipeline_unregister(pipeline->audio_pipeline, pipeline->rsp);
audio_element_deinit(pipeline->rsp);
}
if (pipeline->algo_aec)
{
audio_pipeline_unregister(pipeline->audio_pipeline, pipeline->algo_aec);
audio_element_deinit(pipeline->algo_aec);
}
audio_pipeline_deinit(pipeline->audio_pipeline);
heap_caps_free(pipeline);
};
void recorder_pipeline_run(recorder_pipeline_handle_t pipeline)
{
audio_pipeline_run(pipeline->audio_pipeline);
};
int recorder_pipeline_get_default_read_size(recorder_pipeline_handle_t pipeline)
{
// 60ms
#if (CONFIG_VOLC_AUDIO_G711A)
return (160 * 3);
#else
return 1920;
#endif
};
audio_element_handle_t recorder_pipeline_get_raw_reader(recorder_pipeline_handle_t pipeline)
{
return pipeline->raw_reader;
};
audio_pipeline_handle_t recorder_pipeline_get_pipeline(recorder_pipeline_handle_t pipeline)
{
return pipeline->audio_pipeline;
};
int recorder_pipeline_read(recorder_pipeline_handle_t pipeline, char *buffer, int buf_size)
{
return raw_stream_read(pipeline->raw_reader, buffer, buf_size);
}
static audio_element_handle_t create_player_raw_stream(void)
{
raw_stream_cfg_t raw_cfg = RAW_STREAM_CFG_DEFAULT();
raw_cfg.type = AUDIO_STREAM_READER;
raw_cfg.out_rb_size = 8 * 1024;
return raw_stream_init(&raw_cfg);
}
static audio_element_handle_t create_player_i2s_stream(void)
{
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT_WITH_PARA(I2S_NUM_0, I2S_SAMPLE_RATE, ALGORITHM_STREAM_SAMPLE_BIT, AUDIO_STREAM_WRITER);
i2s_cfg.type = AUDIO_STREAM_WRITER;
#if (CONFIG_ESP32_S3_KORVO2_V3_BOARD || CONFIG_ESP32_S3_ECHOEAR_V1_2_BOARD)
i2s_cfg.need_expand = (16 != 32);
#endif
i2s_cfg.out_rb_size = 8 * 1024;
i2s_cfg.buffer_len = 1416; // 708
i2s_stream_set_channel_type(&i2s_cfg, CHANNEL_FORMAT);
audio_element_handle_t stream = i2s_stream_init(&i2s_cfg);
i2s_stream_set_clk(stream, I2S_SAMPLE_RATE, ALGORITHM_STREAM_SAMPLE_BIT, CHANNEL_NUM);
return stream;
}
static audio_element_handle_t create_player_decoder_stream(void)
{
#if (CONFIG_VOLC_AUDIO_G711A)
g711_decoder_cfg_t g711_dec_cfg = DEFAULT_G711_DECODER_CONFIG();
g711_dec_cfg.out_rb_size = 8 * 1024;
return g711_decoder_init(&g711_dec_cfg);
#else
return NULL;
#endif
}
player_pipeline_handle_t player_pipeline_open(void)
{
player_pipeline_handle_t player_pipeline = heap_caps_calloc(1, sizeof(player_pipeline_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_DEFAULT);
esp_log_level_set(TAG, ESP_LOG_INFO);
assert(player_pipeline != 0);
audio_pipeline_cfg_t pipeline_cfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
player_pipeline->audio_pipeline = audio_pipeline_init(&pipeline_cfg);
mem_assert(pipeline);
player_pipeline->raw_writer = create_player_raw_stream();
audio_pipeline_register(player_pipeline->audio_pipeline, player_pipeline->raw_writer, "raw");
player_pipeline->i2s_stream_writer = create_player_i2s_stream();
audio_pipeline_register(player_pipeline->audio_pipeline, player_pipeline->i2s_stream_writer, "i2s");
player_pipeline->rsp = create_resample_stream(CODEC_SAMPLE_RATE, 1, I2S_SAMPLE_RATE, CHANNEL_NUM);
audio_element_set_output_timeout(player_pipeline->rsp, portMAX_DELAY);
audio_pipeline_register(player_pipeline->audio_pipeline, player_pipeline->rsp, "rsp");
player_pipeline->audio_decoder = create_player_decoder_stream();
if (player_pipeline->audio_decoder != NULL) {
audio_pipeline_register(player_pipeline->audio_pipeline, player_pipeline->audio_decoder, CODEC_NAME);
}
#if (CONFIG_VOLC_AUDIO_G711A)
const char *link_tag[] = {"raw", CODEC_NAME, "rsp", "i2s"};
#else
const char *link_tag[] = {"raw", "rsp", "i2s"};
#endif
audio_pipeline_link(player_pipeline->audio_pipeline, &link_tag[0], sizeof(link_tag) / sizeof(link_tag[0]));
return player_pipeline;
}
void player_pipeline_run(player_pipeline_handle_t player_pipeline)
{
audio_pipeline_run(player_pipeline->audio_pipeline);
};
void player_pipeline_close(player_pipeline_handle_t player_pipeline)
{
audio_pipeline_stop(player_pipeline->audio_pipeline);
audio_pipeline_wait_for_stop(player_pipeline->audio_pipeline);
audio_pipeline_terminate(player_pipeline->audio_pipeline);
if (player_pipeline->raw_writer)
{
audio_pipeline_unregister(player_pipeline->audio_pipeline, player_pipeline->raw_writer);
audio_element_deinit(player_pipeline->raw_writer);
}
if (player_pipeline->rsp)
{
audio_pipeline_unregister(player_pipeline->audio_pipeline, player_pipeline->rsp);
audio_element_deinit(player_pipeline->rsp);
}
if (player_pipeline->i2s_stream_writer)
{
audio_pipeline_unregister(player_pipeline->audio_pipeline, player_pipeline->i2s_stream_writer);
audio_element_deinit(player_pipeline->i2s_stream_writer);
}
if (player_pipeline->audio_decoder) {
audio_pipeline_unregister(player_pipeline->audio_pipeline, player_pipeline->audio_decoder);
audio_element_deinit(player_pipeline->audio_decoder);
}
audio_pipeline_deinit(player_pipeline->audio_pipeline);
heap_caps_free(player_pipeline);
};
int player_pipeline_write(player_pipeline_handle_t player_pipeline, char *buffer, int buf_size)
{
raw_stream_write(player_pipeline->raw_writer, buffer, buf_size);
return 0;
};
@@ -0,0 +1,53 @@
// Copyright (2025) Beijing Volcano Engine Technology Ltd.
// SPDX-License-Identifier: Apache-2.0
#ifndef __PIPELINE_H__
#define __PIPELINE_H__
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include "audio_pipeline.h"
#ifdef __cplusplus
extern "C"
{
#endif
struct recorder_pipeline_t
{
audio_pipeline_handle_t audio_pipeline;
audio_element_handle_t i2s_stream_reader;
audio_element_handle_t audio_encoder;
audio_element_handle_t raw_reader;
audio_element_handle_t rsp;
audio_element_handle_t algo_aec;
};
typedef struct recorder_pipeline_t recorder_pipeline_t, *recorder_pipeline_handle_t;
recorder_pipeline_handle_t recorder_pipeline_open();
void recorder_pipeline_run(recorder_pipeline_handle_t);
void recorder_pipeline_close(recorder_pipeline_handle_t);
int recorder_pipeline_get_default_read_size(recorder_pipeline_handle_t);
int recorder_pipeline_read(recorder_pipeline_handle_t, char *buffer, int buf_size);
struct player_pipeline_t
{
audio_pipeline_handle_t audio_pipeline;
audio_element_handle_t raw_writer;
audio_element_handle_t audio_decoder;
audio_element_handle_t rsp;
audio_element_handle_t i2s_stream_writer;
};
typedef struct player_pipeline_t player_pipeline_t, *player_pipeline_handle_t;
player_pipeline_handle_t player_pipeline_open();
void player_pipeline_run(player_pipeline_handle_t);
void player_pipeline_close(player_pipeline_handle_t);
int player_pipeline_get_default_read_size(player_pipeline_handle_t);
int player_pipeline_write(player_pipeline_handle_t, char *buffer, int buf_size);
void player_pipeline_write_play_buffer_flag(player_pipeline_handle_t player_pipeline);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,714 @@
#include "platform_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "cJSON.h"
#include "esp_crt_bundle.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "esp_random.h"
#include "esp_check.h"
#include "mbedtls/base64.h"
#include "mbedtls/md.h"
#include "mbedtls/sha256.h"
#include "nvs.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#define PLATFORM_API_ORIGIN "https://121.41.46.238"
#define PLATFORM_RESPONSE_MAX (32 * 1024)
#define PLATFORM_FIRMWARE_VERSION "1.0.0-echoear-platform.1"
#define PLATFORM_HARDWARE_AGENT_SDK_VERSION "2.0.0"
static const char *TAG = "platform_client";
typedef struct {
char *data;
size_t length;
size_t capacity;
} response_buffer_t;
static char s_device_id[PLATFORM_DEVICE_ID_MAX];
static char s_device_secret[PLATFORM_DEVICE_SECRET_MAX];
static char s_hardware_id[PLATFORM_HARDWARE_ID_MAX];
static char s_last_error_code[80];
const char *platform_last_error_code(void)
{
return s_last_error_code;
}
static platform_volc_credential_t s_volc;
static int64_t s_config_revision = 1;
static int64_t s_event_sequence = 0;
static int64_t s_turn_sequence = 0;
static platform_agent_session_t s_active_session;
static esp_http_client_handle_t s_http_client;
static StaticSemaphore_t s_http_mutex_storage;
static SemaphoreHandle_t s_http_mutex;
static unsigned s_http_connection_count;
static uint32_t s_heartbeat_interval_ms = 15000;
static TickType_t s_heartbeat_due;
typedef struct {
uint32_t version;
int64_t sequence;
char conversation_id[37];
char provider_session_ref[201];
char session_nonce[257];
} pending_session_t;
static esp_err_t persist_pending_session(const platform_agent_session_t *session, int64_t sequence)
{
nvs_handle_t nvs;
esp_err_t err = nvs_open("platform", NVS_READWRITE, &nvs);
if (err != ESP_OK) return err;
if (session) {
pending_session_t pending = {.version = 1, .sequence = sequence};
snprintf(pending.conversation_id, sizeof(pending.conversation_id), "%s", session->conversation_id);
snprintf(pending.provider_session_ref, sizeof(pending.provider_session_ref), "%s", session->provider_session_ref);
snprintf(pending.session_nonce, sizeof(pending.session_nonce), "%s", session->session_nonce);
err = nvs_set_blob(nvs, "pending_conv", &pending, sizeof(pending));
} else {
err = nvs_erase_key(nvs, "pending_conv");
if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK;
}
if (err == ESP_OK) err = nvs_commit(nvs);
nvs_close(nvs);
return err;
}
static esp_err_t recover_pending_session(void)
{
nvs_handle_t nvs;
esp_err_t err = nvs_open("platform", NVS_READONLY, &nvs);
if (err != ESP_OK) return err;
pending_session_t pending = {0};
size_t size = sizeof(pending);
err = nvs_get_blob(nvs, "pending_conv", &pending, &size);
nvs_close(nvs);
if (err == ESP_ERR_NVS_NOT_FOUND) return ESP_OK;
if (err != ESP_OK) return err;
if (size != sizeof(pending) || pending.version != 1) return ESP_ERR_INVALID_STATE;
platform_agent_session_t session = {0};
memcpy(session.conversation_id, pending.conversation_id, sizeof(session.conversation_id));
memcpy(session.provider_session_ref, pending.provider_session_ref, sizeof(session.provider_session_ref));
memcpy(session.session_nonce, pending.session_nonce, sizeof(session.session_nonce));
session.conversation_id[sizeof(session.conversation_id) - 1] = '\0';
session.provider_session_ref[sizeof(session.provider_session_ref) - 1] = '\0';
session.session_nonce[sizeof(session.session_nonce) - 1] = '\0';
s_event_sequence = pending.sequence;
err = platform_report_session_event(&session, "session_failed", "DEVICE_RESTARTED");
/* The event sequence is persisted before every send. A higher recovery
* sequence can conflict only when this owned session is already terminal. */
if (err == ESP_OK || strcmp(s_last_error_code, "HARDWARE_AGENT_EVENT_CONFLICT") == 0 ||
strcmp(s_last_error_code, "CONVERSATION_NOT_FOUND") == 0) {
ESP_LOGI(TAG, "Recovered previous device session after restart");
return persist_pending_session(NULL, 0);
}
return err;
}
static void copy_json_string(cJSON *object, const char *name, char *target, size_t target_size)
{
cJSON *item = cJSON_GetObjectItemCaseSensitive(object, name);
if (cJSON_IsString(item) && item->valuestring != NULL) {
snprintf(target, target_size, "%s", item->valuestring);
}
}
static esp_err_t load_string(nvs_handle_t nvs, const char *key, char *target, size_t target_size)
{
size_t required = target_size;
esp_err_t err = nvs_get_str(nvs, key, target, &required);
if (err != ESP_OK || required < 2 || required > target_size) {
target[0] = '\0';
return err == ESP_OK ? ESP_ERR_INVALID_SIZE : err;
}
return ESP_OK;
}
esp_err_t platform_identity_save(const char *device_id, const char *device_secret)
{
if (device_id == NULL || device_secret == NULL || strlen(device_id) != 36 ||
strlen(device_secret) < 32 || strlen(device_secret) >= PLATFORM_DEVICE_SECRET_MAX) {
return ESP_ERR_INVALID_ARG;
}
nvs_handle_t nvs;
esp_err_t err = nvs_open("platform", NVS_READWRITE, &nvs);
if (err != ESP_OK) return err;
err = nvs_set_str(nvs, "device_id", device_id);
if (err == ESP_OK) err = nvs_set_str(nvs, "device_secret", device_secret);
if (err == ESP_OK) err = nvs_commit(nvs);
nvs_close(nvs);
if (err == ESP_OK) {
snprintf(s_device_id, sizeof(s_device_id), "%s", device_id);
snprintf(s_device_secret, sizeof(s_device_secret), "%s", device_secret);
}
return err;
}
bool platform_identity_is_configured(void)
{
return strlen(s_device_id) == 36 && strlen(s_device_secret) >= 32;
}
const char *platform_hardware_id(void)
{
return s_hardware_id;
}
const platform_volc_credential_t *platform_volc_credential(void)
{
return s_volc.device_secret[0] ? &s_volc : NULL;
}
esp_err_t platform_client_init(void)
{
if (!s_http_mutex) s_http_mutex = xSemaphoreCreateMutexStatic(&s_http_mutex_storage);
if (!s_http_mutex) return ESP_ERR_NO_MEM;
uint8_t mac[6] = {0};
ESP_RETURN_ON_ERROR(esp_read_mac(mac, ESP_MAC_WIFI_STA), TAG, "read station MAC");
snprintf(s_hardware_id, sizeof(s_hardware_id), "echoear-%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
nvs_handle_t nvs;
esp_err_t err = nvs_open("platform", NVS_READONLY, &nvs);
if (err == ESP_OK) {
(void)load_string(nvs, "device_id", s_device_id, sizeof(s_device_id));
(void)load_string(nvs, "device_secret", s_device_secret, sizeof(s_device_secret));
(void)load_string(nvs, "instance_id", s_volc.instance_id, sizeof(s_volc.instance_id));
(void)load_string(nvs, "product_key", s_volc.product_key, sizeof(s_volc.product_key));
(void)load_string(nvs, "device_name", s_volc.device_name, sizeof(s_volc.device_name));
(void)load_string(nvs, "volc_secret", s_volc.device_secret, sizeof(s_volc.device_secret));
nvs_close(nvs);
}
snprintf(s_volc.hardware_id, sizeof(s_volc.hardware_id), "%s", s_hardware_id);
ESP_LOGI(TAG, "hardware_id=%s platform_identity=%s", s_hardware_id,
platform_identity_is_configured() ? "configured" : "missing");
return ESP_OK;
}
static void timestamp_now(char output[32])
{
time_t now = time(NULL);
struct tm utc = {0};
gmtime_r(&now, &utc);
strftime(output, 32, "%Y-%m-%dT%H:%M:%S.000Z", &utc);
}
static void random_hex(char *output, size_t bytes)
{
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < bytes; ++i) {
uint8_t value = (uint8_t)esp_random();
output[i * 2] = hex[value >> 4];
output[i * 2 + 1] = hex[value & 0x0f];
}
output[bytes * 2] = '\0';
}
static void sha256_hex(const char *body, char output[65])
{
unsigned char digest[32];
mbedtls_sha256((const unsigned char *)body, strlen(body), digest, 0);
for (size_t i = 0; i < sizeof(digest); ++i) snprintf(output + i * 2, 3, "%02x", digest[i]);
}
static esp_err_t signature_header(const char *method, const char *path, const char *timestamp,
const char *nonce, const char *digest, char output[64])
{
char canonical[768];
int written = snprintf(canonical, sizeof(canonical), "%s\n%s\n%s\n%s\n%s",
method, path, timestamp, nonce, digest);
if (written <= 0 || written >= (int)sizeof(canonical)) return ESP_ERR_INVALID_SIZE;
unsigned char mac[32];
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (mbedtls_md_hmac(info, (const unsigned char *)s_device_secret, strlen(s_device_secret),
(const unsigned char *)canonical, written, mac) != 0) return ESP_FAIL;
unsigned char encoded[48];
size_t encoded_length = 0;
if (mbedtls_base64_encode(encoded, sizeof(encoded), &encoded_length, mac, sizeof(mac)) != 0) return ESP_FAIL;
while (encoded_length > 0 && encoded[encoded_length - 1] == '=') --encoded_length;
for (size_t i = 0; i < encoded_length; ++i) {
if (encoded[i] == '+') encoded[i] = '-';
else if (encoded[i] == '/') encoded[i] = '_';
}
encoded[encoded_length] = '\0';
snprintf(output, 64, "%s", encoded);
return ESP_OK;
}
static esp_err_t http_event(esp_http_client_event_t *event)
{
if (event->event_id == HTTP_EVENT_ON_CONNECTED) ++s_http_connection_count;
response_buffer_t *buffer = (response_buffer_t *)event->user_data;
if (event->event_id != HTTP_EVENT_ON_DATA || buffer == NULL || event->data_len <= 0) return ESP_OK;
if (buffer->length + event->data_len + 1 > buffer->capacity) return ESP_ERR_NO_MEM;
memcpy(buffer->data + buffer->length, event->data, event->data_len);
buffer->length += event->data_len;
buffer->data[buffer->length] = '\0';
return ESP_OK;
}
static esp_err_t platform_request_impl(const char *method, const char *path, const char *body,
const char *idempotency_key, char **response_out)
{
s_last_error_code[0] = '\0';
if (!platform_identity_is_configured()) return ESP_ERR_INVALID_STATE;
if (body == NULL) body = "";
char url[384];
snprintf(url, sizeof(url), "%s%s", PLATFORM_API_ORIGIN, path);
response_buffer_t response = {
.data = calloc(1, PLATFORM_RESPONSE_MAX), .capacity = PLATFORM_RESPONSE_MAX,
};
if (response.data == NULL) return ESP_ERR_NO_MEM;
esp_http_client_config_t config = {
.url = url,
.event_handler = http_event,
.user_data = &response,
.timeout_ms = 15000,
.crt_bundle_attach = esp_crt_bundle_attach,
.buffer_size = 2048,
.buffer_size_tx = 2048,
.keep_alive_enable = true,
};
if (!s_http_client) s_http_client = esp_http_client_init(&config);
esp_http_client_handle_t client = s_http_client;
if (client == NULL) {
free(response.data);
return ESP_ERR_NO_MEM;
}
esp_http_client_method_t verb = strcmp(method, "POST") == 0 ? HTTP_METHOD_POST : HTTP_METHOD_GET;
esp_err_t err = esp_http_client_set_url(client, url);
if (err == ESP_OK) err = esp_http_client_set_user_data(client, &response);
if (err == ESP_OK) err = esp_http_client_set_method(client, verb);
/* Reused handles must not inherit a preceding request's body or key. */
if (err == ESP_OK) err = esp_http_client_set_post_field(client, body, strlen(body));
if (!idempotency_key) (void)esp_http_client_delete_header(client, "Idempotency-Key");
if (!body[0]) (void)esp_http_client_delete_header(client, "Content-Type");
char timestamp[32], nonce[33], digest[65], signature[64];
timestamp_now(timestamp);
random_hex(nonce, 16);
sha256_hex(body, digest);
if (err == ESP_OK) err = signature_header(method, path, timestamp, nonce, digest, signature);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Id", s_device_id);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Timestamp", timestamp);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Nonce", nonce);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Content-SHA256", digest);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Signature", signature);
if (err == ESP_OK) err = esp_http_client_set_header(client, "Accept", "application/json");
if (err == ESP_OK && idempotency_key != NULL) err = esp_http_client_set_header(client, "Idempotency-Key", idempotency_key);
if (err == ESP_OK && body[0] != '\0') {
err = esp_http_client_set_header(client, "Content-Type", "application/json");
}
TickType_t request_started = xTaskGetTickCount();
unsigned connection_count = s_http_connection_count;
if (err == ESP_OK) err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
ESP_LOGI(TAG, "PLATFORM_HTTP method=%s status=%d elapsed_ms=%u new_connection=%u",
method, status, (unsigned)((xTaskGetTickCount() - request_started) * portTICK_PERIOD_MS),
s_http_connection_count - connection_count);
(void)esp_http_client_set_user_data(client, NULL);
(void)esp_http_client_set_post_field(client, NULL, 0);
if (err != ESP_OK) {
/* An uncertain POST is not replayed automatically. Reconnect on the
* next request while retaining the existing event/idempotency rules. */
esp_http_client_cleanup(client);
s_http_client = NULL;
}
if (err == ESP_OK && (status < 200 || status >= 300)) {
// Log only a machine-readable error code, never credentials or response bodies.
cJSON *root = cJSON_Parse(response.data);
cJSON *error = cJSON_GetObjectItemCaseSensitive(root, "error");
cJSON *code = cJSON_GetObjectItemCaseSensitive(error, "code");
if (cJSON_IsString(code) && code->valuestring) {
size_t length = strlen(code->valuestring);
if (length > 0 && length < sizeof(s_last_error_code) &&
strspn(code->valuestring, "ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789") == length) {
snprintf(s_last_error_code, sizeof(s_last_error_code), "%s", code->valuestring);
}
}
cJSON_Delete(root);
ESP_LOGE(TAG, "%s %s failed: HTTP %d", method, path, status);
ESP_LOGE(TAG, "Platform error code: %s", s_last_error_code[0] ? s_last_error_code : "UNKNOWN");
err = ESP_FAIL;
}
if (err == ESP_OK && response_out != NULL) {
*response_out = response.data;
response.data = NULL;
}
free(response.data);
return err;
}
static esp_err_t platform_request(const char *method, const char *path, const char *body,
const char *idempotency_key, char **response_out)
{
if (!s_http_mutex) return ESP_ERR_INVALID_STATE;
xSemaphoreTake(s_http_mutex, portMAX_DELAY);
esp_err_t err = platform_request_impl(method, path, body, idempotency_key, response_out);
xSemaphoreGive(s_http_mutex);
return err;
}
static cJSON *response_data(char *response, cJSON **root_out)
{
cJSON *root = cJSON_Parse(response);
if (root == NULL) return NULL;
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
if (!cJSON_IsObject(data)) {
cJSON_Delete(root);
return NULL;
}
*root_out = root;
return data;
}
static esp_err_t save_volc_credential(void)
{
nvs_handle_t nvs;
esp_err_t err = nvs_open("platform", NVS_READWRITE, &nvs);
if (err != ESP_OK) return err;
if (err == ESP_OK) err = nvs_set_str(nvs, "instance_id", s_volc.instance_id);
if (err == ESP_OK) err = nvs_set_str(nvs, "product_key", s_volc.product_key);
if (err == ESP_OK) err = nvs_set_str(nvs, "device_name", s_volc.device_name);
if (err == ESP_OK) err = nvs_set_str(nvs, "volc_secret", s_volc.device_secret);
if (err == ESP_OK) err = nvs_commit(nvs);
nvs_close(nvs);
return err;
}
esp_err_t platform_provision_volc_credential(void)
{
char body[160];
snprintf(body, sizeof(body), "{\"hardware_id\":\"%s\"}", s_hardware_id);
char key[96];
snprintf(key, sizeof(key), "echoear-provision-%s", s_hardware_id);
char *response = NULL;
esp_err_t err = platform_request("POST", "/device/v3/hardware-agent-credentials:provision", body, key, &response);
if (err != ESP_OK) return err;
cJSON *root = NULL;
cJSON *data = response_data(response, &root);
if (data == NULL) { free(response); return ESP_FAIL; }
memset(&s_volc, 0, sizeof(s_volc));
copy_json_string(data, "instance_id", s_volc.instance_id, sizeof(s_volc.instance_id));
copy_json_string(data, "product_key", s_volc.product_key, sizeof(s_volc.product_key));
copy_json_string(data, "device_name", s_volc.device_name, sizeof(s_volc.device_name));
copy_json_string(data, "device_secret", s_volc.device_secret, sizeof(s_volc.device_secret));
snprintf(s_volc.hardware_id, sizeof(s_volc.hardware_id), "%s", s_hardware_id);
cJSON_Delete(root);
free(response);
if (!s_volc.instance_id[0] || !s_volc.product_key[0] || !s_volc.device_name[0] || strlen(s_volc.device_secret) < 16) return ESP_FAIL;
ESP_LOGI(TAG, "provider credential received for device_name=%s", s_volc.device_name);
return save_volc_credential();
}
static esp_err_t refresh_config_revision(void)
{
char *response = NULL;
esp_err_t err = platform_request("GET", "/device/v2/config", "", NULL, &response);
if (err != ESP_OK) return err;
cJSON *root = NULL;
cJSON *data = response_data(response, &root);
cJSON *revision = data ? cJSON_GetObjectItemCaseSensitive(data, "revision") : NULL;
if (cJSON_IsNumber(revision) && revision->valuedouble >= 1) s_config_revision = (int64_t)revision->valuedouble;
cJSON_Delete(root);
free(response);
return data == NULL ? ESP_FAIL : ESP_OK;
}
static esp_err_t report_bootstrap(void)
{
char timestamp[32], path[320];
timestamp_now(timestamp);
snprintf(path, sizeof(path),
"/device/v2/bootstrap?firmware_version=%s&device_time=%s&board_rev=echoear&rtc_sdk_version=%s&codec=pcm16&sample_rate=16000&network=wifi",
PLATFORM_FIRMWARE_VERSION, timestamp, PLATFORM_HARDWARE_AGENT_SDK_VERSION);
char *response = NULL;
esp_err_t err = platform_request("GET", path, "", NULL, &response);
if (err != ESP_OK) return err;
cJSON *root = NULL;
cJSON *data = response_data(response, &root);
cJSON *device = cJSON_GetObjectItemCaseSensitive(data, "device");
cJSON *compatible = cJSON_GetObjectItemCaseSensitive(device, "firmware_compatible");
cJSON *minimum = cJSON_GetObjectItemCaseSensitive(device, "minimum_firmware_version");
ESP_LOGI(TAG, "Firmware report: current=%s minimum=%s compatible=%d",
PLATFORM_FIRMWARE_VERSION, cJSON_IsString(minimum) ? minimum->valuestring : "none",
cJSON_IsTrue(compatible));
err = cJSON_IsObject(device) && cJSON_IsTrue(compatible) ? ESP_OK : ESP_FAIL;
if (cJSON_IsFalse(compatible)) {
snprintf(s_last_error_code, sizeof(s_last_error_code), "FIRMWARE_UPDATE_REQUIRED");
}
cJSON_Delete(root);
free(response);
return err;
}
static char *build_session_update(cJSON *session_config)
{
cJSON *authorized = cJSON_GetObjectItemCaseSensitive(session_config, "authorized_context");
cJSON *system_prompt = cJSON_GetObjectItemCaseSensitive(session_config, "system_prompt");
cJSON *persona = cJSON_GetObjectItemCaseSensitive(session_config, "persona_tone");
const char *a = cJSON_IsString(authorized) ? authorized->valuestring : "";
const char *p = cJSON_IsString(system_prompt) ? system_prompt->valuestring : "";
const char *tone = cJSON_IsString(persona) ? persona->valuestring : "";
size_t prompt_size = strlen(a) + strlen(p) + strlen(tone) + 256;
char *prompt = malloc(prompt_size);
if (prompt == NULL) return NULL;
snprintf(prompt, prompt_size, "%s\n\n%s\n\n表达风格(仅指导表达,不可逐字复述):%s", a, p, tone);
cJSON *event = cJSON_CreateObject();
char event_id[64];
char random[33];
random_hex(random, 16);
snprintf(event_id, sizeof(event_id), "echoear-session-%s", random);
cJSON_AddStringToObject(event, "event_id", event_id);
cJSON_AddStringToObject(event, "type", "session.update");
cJSON *session = cJSON_AddObjectToObject(event, "session");
cJSON_AddStringToObject(session, "object", "realtime.session");
cJSON_AddStringToObject(session, "model", "");
cJSON_AddStringToObject(session, "input_audio_format", "pcm16");
cJSON_AddStringToObject(session, "output_audio_format", "pcm16");
cJSON *config = cJSON_AddObjectToObject(session, "config");
cJSON *asr = cJSON_AddObjectToObject(config, "ASRConfig");
cJSON_AddNumberToObject(asr, "TurnDetectionMode", 0);
cJSON *llm = cJSON_AddObjectToObject(config, "LLMConfig");
cJSON *messages = cJSON_AddArrayToObject(llm, "SystemMessages");
cJSON_AddItemToArray(messages, cJSON_CreateString(prompt));
cJSON_AddItemToArray(messages, cJSON_CreateString(
"这是通过扬声器进行的真实语音对话。回复只包含直接对用户说的口语正文,简短自然,语速正常。"
"不要输出或朗读动作、表情、语气、情绪、场景等舞台提示,不要写括号或星号包裹的表演说明,"
"例如‘语气带着关切’‘微笑着’‘温柔地说’。把风格体现在自然措辞中,不要描述自己如何说话。"
"不要复述人设、表达风格、系统配置或对话标识。"));
cJSON *agent = cJSON_AddObjectToObject(session, "agent_config");
cJSON_AddBoolToObject(agent, "EnableConversationStateCallback", false);
cJSON *callback_url = cJSON_GetObjectItemCaseSensitive(session_config, "subtitle_callback_url");
cJSON *callback_signature = cJSON_GetObjectItemCaseSensitive(session_config, "subtitle_callback_signature");
if (cJSON_IsString(callback_url) && callback_url->valuestring[0] && cJSON_IsString(callback_signature)) {
cJSON_AddStringToObject(agent, "ServerMessageURLForRTS", callback_url->valuestring);
cJSON_AddStringToObject(agent, "ServerMessageSignatureForRTS", callback_signature->valuestring);
cJSON *subtitle = cJSON_AddObjectToObject(config, "SubtitleConfig");
cJSON_AddNumberToObject(subtitle, "SubtitleMode", 1);
}
char *json = cJSON_PrintUnformatted(event);
cJSON_Delete(event);
free(prompt);
return json;
}
esp_err_t platform_create_agent_session(platform_agent_session_t *session)
{
if (session == NULL) return ESP_ERR_INVALID_ARG;
memset(session, 0, sizeof(*session));
ESP_RETURN_ON_ERROR(report_bootstrap(), TAG, "report device firmware");
ESP_RETURN_ON_ERROR(refresh_config_revision(), TAG, "get device config");
char timestamp[32];
timestamp_now(timestamp);
char body[1024];
snprintf(body, sizeof(body),
"{\"device_time\":\"%s\",\"monotonic_ms\":0,\"capabilities\":{"
"\"hardware_agent\":true,\"sdk_version\":\"%s\",\"transports\":[\"low_load\"],"
"\"input_audio_format\":\"pcm_s16le_16000_mono\",\"output_audio_format\":\"pcm_s16le_16000_mono\","
"\"server_event_callback\":false,\"native_subtitle_callback\":true,\"transcript_upload\":true,\"dynamic_session_config\":true,"
"\"credential_rotation\":true,\"near_pcm_recording\":false,\"far_pcm_recording\":false},"
"\"network\":{\"type\":\"wifi\"},\"requested_goal\":\"open_conversation\",\"config_revision\":%lld}",
timestamp, PLATFORM_HARDWARE_AGENT_SDK_VERSION, (long long)s_config_revision);
char random[33], key[64];
random_hex(random, 16);
snprintf(key, sizeof(key), "echoear-session-%s", random);
char *response = NULL;
esp_err_t err = platform_request("POST", "/device/v3/agent-sessions", body, key, &response);
if (err != ESP_OK) return err;
cJSON *root = NULL;
cJSON *data = response_data(response, &root);
cJSON *runtime = data ? cJSON_GetObjectItemCaseSensitive(data, "runtime") : NULL;
cJSON *session_config = data ? cJSON_GetObjectItemCaseSensitive(data, "session_config") : NULL;
cJSON *limits = data ? cJSON_GetObjectItemCaseSensitive(data, "session") : NULL;
cJSON *interval = limits ? cJSON_GetObjectItemCaseSensitive(limits, "heartbeat_interval_sec") : NULL;
s_heartbeat_interval_ms = cJSON_IsNumber(interval) && interval->valueint >= 1 && interval->valueint <= 60
? (uint32_t)interval->valueint * 1000 : 15000;
if (!cJSON_IsObject(runtime) || !cJSON_IsObject(session_config)) {
cJSON_Delete(root); free(response); return ESP_FAIL;
}
copy_json_string(data, "conversation_id", session->conversation_id, sizeof(session->conversation_id));
copy_json_string(runtime, "bot_id", session->bot_id, sizeof(session->bot_id));
copy_json_string(runtime, "provider_session_ref", session->provider_session_ref, sizeof(session->provider_session_ref));
copy_json_string(runtime, "session_nonce", session->session_nonce, sizeof(session->session_nonce));
copy_json_string(runtime, "config_digest", session->config_digest, sizeof(session->config_digest));
session->session_update_json = build_session_update(session_config);
cJSON *subtitle_source = cJSON_GetObjectItemCaseSensitive(session_config, "subtitle_source");
session->native_subtitle_primary = cJSON_IsString(subtitle_source) && strcmp(subtitle_source->valuestring, "provider") == 0;
cJSON_Delete(root);
free(response);
if (!session->conversation_id[0] || !session->bot_id[0] || !session->provider_session_ref[0] ||
!session->session_nonce[0] || !session->config_digest[0] || session->session_update_json == NULL) {
platform_agent_session_free(session);
return ESP_FAIL;
}
ESP_LOGI(TAG, "platform session ready conversation_id=%s", session->conversation_id);
return persist_pending_session(session, s_event_sequence);
}
static esp_err_t report_event(const platform_agent_session_t *session, const char *event_type,
const char *provider_connection_ref, const char *error_code)
{
if (session == NULL || event_type == NULL) return ESP_ERR_INVALID_ARG;
char timestamp[32], random[33], event_id[64], path[256];
timestamp_now(timestamp);
random_hex(random, 16);
snprintf(event_id, sizeof(event_id), "echoear-event-%s", random);
snprintf(path, sizeof(path), "/device/v3/agent-sessions/%s/events", session->conversation_id);
cJSON *body = cJSON_CreateObject();
cJSON_AddStringToObject(body, "event_id", event_id);
cJSON_AddNumberToObject(body, "sequence_no", ++s_event_sequence);
esp_err_t saved = persist_pending_session(session, s_event_sequence);
if (saved != ESP_OK) { cJSON_Delete(body); return saved; }
cJSON_AddStringToObject(body, "event_type", event_type);
cJSON_AddStringToObject(body, "occurred_at", timestamp);
cJSON_AddStringToObject(body, "provider_session_ref", session->provider_session_ref);
cJSON_AddStringToObject(body, "session_nonce", session->session_nonce);
if (strcmp(event_type, "session_started") == 0) {
cJSON_AddStringToObject(body, "provider_connection_ref", provider_connection_ref);
cJSON_AddStringToObject(body, "bot_id", session->bot_id);
cJSON_AddStringToObject(body, "transport", "low_load");
cJSON_AddStringToObject(body, "config_digest", session->config_digest);
cJSON_AddBoolToObject(body, "dynamic_config_applied", true);
}
if (error_code != NULL && error_code[0]) cJSON_AddStringToObject(body, "error_code", error_code);
char *json = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
if (json == NULL) return ESP_ERR_NO_MEM;
char *response = NULL;
esp_err_t err = platform_request("POST", path, json, NULL, &response);
free(json);
if (err == ESP_OK && strcmp(event_type, "session_updated") == 0) {
cJSON *root = NULL;
cJSON *data = response_data(response, &root);
cJSON *action = data ? cJSON_GetObjectItemCaseSensitive(data, "action") : NULL;
bool stop = cJSON_IsString(action) && strcmp(action->valuestring, "stop_session") == 0;
ESP_LOGI(TAG, "PLATFORM_HEARTBEAT stop=%d", stop);
if (!data) err = ESP_FAIL;
else if (stop) err = ESP_ERR_INVALID_STATE;
cJSON_Delete(root);
}
free(response);
return err;
}
esp_err_t platform_report_session_started(const platform_agent_session_t *session,
const char *provider_connection_ref)
{
return report_event(session, "session_started", provider_connection_ref, NULL);
}
esp_err_t platform_report_session_event(const platform_agent_session_t *session,
const char *event_type, const char *error_code)
{
return report_event(session, event_type, NULL, error_code);
}
void platform_agent_session_free(platform_agent_session_t *session)
{
if (session == NULL) return;
free(session->session_update_json);
memset(session, 0, sizeof(*session));
}
const char *platform_runtime_instance_id(void) { return s_volc.instance_id; }
const char *platform_runtime_product_key(void) { return s_volc.product_key; }
const char *platform_runtime_device_name(void) { return s_volc.device_name; }
const char *platform_runtime_device_secret(void) { return s_volc.device_secret; }
esp_err_t platform_conversation_begin(void)
{
platform_agent_session_free(&s_active_session);
ESP_RETURN_ON_ERROR(recover_pending_session(), TAG, "recover interrupted session");
s_event_sequence = 0;
s_turn_sequence = 0;
return platform_create_agent_session(&s_active_session);
}
const char *platform_conversation_bot_id(void) { return s_active_session.bot_id; }
const char *platform_conversation_update_json(void) { return s_active_session.session_update_json; }
esp_err_t platform_conversation_started(const char *provider_connection_ref)
{
if (provider_connection_ref == NULL || provider_connection_ref[0] == '\0') return ESP_ERR_INVALID_ARG;
esp_err_t err = platform_report_session_started(&s_active_session, provider_connection_ref);
s_heartbeat_due = xTaskGetTickCount() + pdMS_TO_TICKS(s_heartbeat_interval_ms);
return err;
}
int platform_conversation_poll(void)
{
if (!s_active_session.conversation_id[0]) return 1;
TickType_t now = xTaskGetTickCount();
if ((int32_t)(now - s_heartbeat_due) < 0) return 0;
esp_err_t err = platform_report_session_event(&s_active_session, "session_updated", NULL);
s_heartbeat_due = xTaskGetTickCount() + pdMS_TO_TICKS(err == ESP_OK ? s_heartbeat_interval_ms : 5000);
if (err == ESP_ERR_INVALID_STATE) return 1;
if (err != ESP_OK) ESP_LOGW(TAG, "PLATFORM_HEARTBEAT failed=%s", esp_err_to_name(err));
return 0;
}
esp_err_t platform_conversation_turn(const char *speaker, const char *source_id, const char *text)
{
if (s_active_session.native_subtitle_primary) return ESP_OK;
if (speaker == NULL || text == NULL || text[0] == '\0' || s_active_session.conversation_id[0] == '\0') return ESP_ERR_INVALID_ARG;
char timestamp[32], path[256], key[96], random[33];
timestamp_now(timestamp);
random_hex(random, 16);
snprintf(path, sizeof(path), "/device/v3/agent-sessions/%s/turns:batch", s_active_session.conversation_id);
snprintf(key, sizeof(key), "echoear-turn-%s", random);
cJSON *payload = cJSON_CreateObject();
cJSON *turns = cJSON_AddArrayToObject(payload, "turns");
cJSON *turn = cJSON_CreateObject();
cJSON_AddItemToArray(turns, turn);
char turn_key[256];
snprintf(turn_key, sizeof(turn_key), "echoear-%s-%s", speaker, source_id && source_id[0] ? source_id : random);
cJSON_AddStringToObject(turn, "turn_key", turn_key);
cJSON_AddNumberToObject(turn, "sequence_no", ++s_turn_sequence);
cJSON_AddStringToObject(turn, "speaker", speaker);
cJSON_AddStringToObject(turn, "start_at", timestamp);
cJSON_AddStringToObject(turn, "end_at", timestamp);
cJSON_AddStringToObject(turn, "final_text", text);
cJSON_AddBoolToObject(turn, "interrupted", false);
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) return ESP_ERR_NO_MEM;
char *response = NULL;
esp_err_t err = platform_request("POST", path, json, key, &response);
free(json);
if (err == ESP_OK) {
cJSON *root = NULL;
cJSON *data = response_data(response, &root);
cJSON *accepted = data ? cJSON_GetObjectItemCaseSensitive(data, "accepted_count") : NULL;
cJSON *duplicate = data ? cJSON_GetObjectItemCaseSensitive(data, "duplicate_count") : NULL;
if (!cJSON_IsNumber(accepted) || !cJSON_IsNumber(duplicate) || accepted->valueint + duplicate->valueint != 1) {
err = ESP_FAIL;
} else {
ESP_LOGI(TAG, "PLATFORM_TURN speaker=%s sequence=%lld accepted=%d duplicate=%d",
speaker, (long long)s_turn_sequence, accepted->valueint, duplicate->valueint);
}
cJSON_Delete(root);
}
if (err != ESP_OK) ESP_LOGE(TAG, "PLATFORM_TURN upload failed sequence=%lld", (long long)s_turn_sequence);
free(response);
return err;
}
void platform_conversation_finish(bool failed, const char *error_code)
{
if (s_active_session.conversation_id[0]) {
esp_err_t err = platform_report_session_event(&s_active_session,
failed ? "session_failed" : "session_ended",
failed ? error_code : NULL);
if (err == ESP_OK) (void)persist_pending_session(NULL, 0);
}
platform_agent_session_free(&s_active_session);
}
@@ -0,0 +1,63 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
#define PLATFORM_DEVICE_ID_MAX 37
#define PLATFORM_DEVICE_SECRET_MAX 129
#define PLATFORM_HARDWARE_ID_MAX 65
typedef struct {
char instance_id[201];
char product_key[201];
char device_name[129];
char device_secret[257];
char hardware_id[PLATFORM_HARDWARE_ID_MAX];
} platform_volc_credential_t;
typedef struct {
char conversation_id[37];
char bot_id[201];
char provider_session_ref[201];
char session_nonce[257];
char config_digest[65];
char *session_update_json;
bool native_subtitle_primary;
} platform_agent_session_t;
esp_err_t platform_identity_save(const char *device_id, const char *device_secret);
bool platform_identity_is_configured(void);
const char *platform_last_error_code(void);
esp_err_t platform_client_init(void);
const char *platform_hardware_id(void);
const platform_volc_credential_t *platform_volc_credential(void);
esp_err_t platform_provision_volc_credential(void);
esp_err_t platform_create_agent_session(platform_agent_session_t *session);
esp_err_t platform_report_session_started(const platform_agent_session_t *session,
const char *provider_connection_ref);
esp_err_t platform_report_session_event(const platform_agent_session_t *session,
const char *event_type,
const char *error_code);
void platform_agent_session_free(platform_agent_session_t *session);
/* Link-safe hooks used by the vendor application component. */
const char *platform_runtime_instance_id(void);
const char *platform_runtime_product_key(void);
const char *platform_runtime_device_name(void);
const char *platform_runtime_device_secret(void);
esp_err_t platform_conversation_begin(void);
const char *platform_conversation_bot_id(void);
const char *platform_conversation_update_json(void);
esp_err_t platform_conversation_started(const char *provider_connection_ref);
int platform_conversation_poll(void);
esp_err_t platform_conversation_turn(const char *speaker, const char *source_id, const char *text);
void platform_conversation_finish(bool failed, const char *error_code);
#ifdef __cplusplus
}
#endif