2026-09-06 17:04:11 +08:00
|
|
|
#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)
|
2026-09-06 20:16:01 +08:00
|
|
|
#define PLATFORM_FIRMWARE_VERSION "1.2.0-echoear-companion.1"
|
2026-09-06 17:04:11 +08:00
|
|
|
#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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 18:15:32 +08:00
|
|
|
esp_err_t platform_recover_pending_conversation(void)
|
|
|
|
|
{
|
|
|
|
|
return recover_pending_session();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 17:04:11 +08:00
|
|
|
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 : "";
|
2026-09-06 17:27:11 +08:00
|
|
|
const char *voice_rules =
|
2026-09-06 20:16:01 +08:00
|
|
|
"你在和一位长者进行真实语音对话。先接住对方刚说的内容,再决定是否追问。"
|
|
|
|
|
"通常回复一到两句,每轮最多问一个问题;没有自然问题时可以只回应,不要为了延续对话连续提问。"
|
|
|
|
|
"长者分享经历时,先回应一个具体细节,再视情况问一个开放而具体的问题,避免审问式追问。"
|
|
|
|
|
"历史资料只在当前话题自然相关时使用,不要突然提起,不要把推断当成事实。"
|
|
|
|
|
"对方表示不想说、累了、忘了或转移话题时,尊重并换话题或结束。不要使用催促、埋怨或让对方有负担的开场白。"
|
|
|
|
|
"回复只包含直接说出口的口语正文,语速自然。不要输出或朗读动作、表情、语气、情绪、场景等舞台提示,"
|
|
|
|
|
"不要使用括号或星号包裹表演说明。不要复述人设、系统配置或对话标识。";
|
2026-09-06 17:27:11 +08:00
|
|
|
size_t prompt_size = strlen(a) + strlen(p) + strlen(tone) + strlen(voice_rules) + 256;
|
2026-09-06 17:04:11 +08:00
|
|
|
char *prompt = malloc(prompt_size);
|
|
|
|
|
if (prompt == NULL) return NULL;
|
2026-09-06 17:27:11 +08:00
|
|
|
snprintf(prompt, prompt_size, "%s\n\n%s\n\n表达风格(仅指导表达,不可逐字复述):%s\n\n%s", a, p, tone, voice_rules);
|
2026-09-06 17:04:11 +08:00
|
|
|
|
|
|
|
|
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));
|
2026-09-06 17:27:11 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-06 17:04:11 +08:00
|
|
|
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);
|
|
|
|
|
}
|