#include "platform_client.h" #include #include #include #include #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.2.0-echoear-companion.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; } esp_err_t platform_recover_pending_conversation(void) { return recover_pending_session(); } 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 : ""; const char *voice_rules = "你在和一位长者进行真实语音对话。先接住对方刚说的内容,再决定是否追问。" "通常回复一到两句,每轮最多问一个问题;没有自然问题时可以只回应,不要为了延续对话连续提问。" "长者分享经历时,先回应一个具体细节,再视情况问一个开放而具体的问题,避免审问式追问。" "历史资料只在当前话题自然相关时使用,不要突然提起,不要把推断当成事实。" "对方表示不想说、累了、忘了或转移话题时,尊重并换话题或结束。不要使用催促、埋怨或让对方有负担的开场白。" "回复只包含直接说出口的口语正文,语速自然。不要输出或朗读动作、表情、语气、情绪、场景等舞台提示," "不要使用括号或星号包裹表演说明。不要复述人设、系统配置或对话标识。"; size_t prompt_size = strlen(a) + strlen(p) + strlen(tone) + strlen(voice_rules) + 256; char *prompt = malloc(prompt_size); if (prompt == NULL) return NULL; snprintf(prompt, prompt_size, "%s\n\n%s\n\n表达风格(仅指导表达,不可逐字复述):%s\n\n%s", a, p, tone, voice_rules); 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 *model_mode = cJSON_GetObjectItemCaseSensitive(session_config, "model_mode"); if (cJSON_IsString(model_mode) && strcmp(model_mode->valuestring, "s2s") == 0) { cJSON *s2s = cJSON_AddObjectToObject(config, "S2SConfig"); cJSON *provider_params = cJSON_AddObjectToObject(s2s, "ProviderParams"); cJSON *dialog = cJSON_AddObjectToObject(provider_params, "dialog"); cJSON_AddStringToObject(dialog, "system_role", prompt); cJSON_AddStringToObject(dialog, "speaking_style", tone); cJSON *dialog_id = cJSON_GetObjectItemCaseSensitive(session_config, "dialog_id"); if (cJSON_IsString(dialog_id) && dialog_id->valuestring[0]) { cJSON_AddStringToObject(dialog, "dialog_id", dialog_id->valuestring); } } cJSON *memory = cJSON_GetObjectItemCaseSensitive(session_config, "memory"); if (cJSON_IsObject(memory)) { cJSON *collection = cJSON_GetObjectItemCaseSensitive(memory, "collection_name"); cJSON *user_id = cJSON_GetObjectItemCaseSensitive(memory, "user_id"); cJSON *assistant_id = cJSON_GetObjectItemCaseSensitive(memory, "assistant_id"); cJSON *memory_types = cJSON_GetObjectItemCaseSensitive(memory, "memory_types"); cJSON *limit = cJSON_GetObjectItemCaseSensitive(memory, "limit"); cJSON *transition = cJSON_GetObjectItemCaseSensitive(memory, "transition_words"); if (cJSON_IsString(collection) && collection->valuestring[0] && cJSON_IsString(user_id) && user_id->valuestring[0] && cJSON_IsArray(memory_types)) { cJSON *memory_config = cJSON_AddObjectToObject(config, "MemoryConfig"); cJSON_AddBoolToObject(memory_config, "Enable", true); cJSON_AddStringToObject(memory_config, "Provider", "volc"); cJSON *memory_params = cJSON_AddObjectToObject(memory_config, "ProviderParams"); cJSON_AddStringToObject(memory_params, "collection_name", collection->valuestring); cJSON_AddNumberToObject(memory_params, "limit", cJSON_IsNumber(limit) && limit->valueint > 0 ? limit->valueint : 5); cJSON *filter = cJSON_AddObjectToObject(memory_params, "filter"); cJSON *users = cJSON_AddArrayToObject(filter, "user_id"); cJSON_AddItemToArray(users, cJSON_CreateString(user_id->valuestring)); if (cJSON_IsString(assistant_id) && assistant_id->valuestring[0]) { cJSON *assistants = cJSON_AddArrayToObject(filter, "assistant_id"); cJSON_AddItemToArray(assistants, cJSON_CreateString(assistant_id->valuestring)); } cJSON_AddItemToObject(filter, "memory_type", cJSON_Duplicate(memory_types, true)); if (cJSON_IsString(transition) && transition->valuestring[0]) { cJSON_AddStringToObject(memory_params, "transition_words", transition->valuestring); } } } 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); }