ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
812 lines
40 KiB
C
812 lines
40 KiB
C
#include "platform_client.h"
|
|
#include "ble_cloud.h"
|
|
#include "call_client.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
#include <stdatomic.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 (40 * 1024)
|
|
#define PLATFORM_FIRMWARE_VERSION "1.2.2-echoear-companion.3"
|
|
#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;
|
|
static atomic_uint s_local_lease_deadline;
|
|
static atomic_bool s_local_lease_active;
|
|
|
|
bool platform_conversation_audio_authorized(void)
|
|
{
|
|
if (ble_cloud_configured()) return call_client_audio_allowed();
|
|
return atomic_load(&s_local_lease_active) &&
|
|
(int32_t)(atomic_load(&s_local_lease_deadline) - xTaskGetTickCount()) > 0;
|
|
}
|
|
|
|
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 ble_cloud_configured() || (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() && !ble_cloud_configured()) return ESP_ERR_INVALID_STATE;
|
|
if (body == NULL) body = "";
|
|
char url[384];
|
|
snprintf(url, sizeof(url), "%s%s", ble_cloud_configured()?ble_cloud_origin():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,
|
|
.disable_auto_redirect = 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 (ble_cloud_configured()) {
|
|
char request_id[37]; if (!idempotency_key) { ble_random_uuid(request_id); idempotency_key=request_id; }
|
|
if (err == ESP_OK) err=ble_cloud_headers(client,method,path,body,idempotency_key);
|
|
// Header values are copied by ESP-IDF; keep the idempotency pointer valid below.
|
|
idempotency_key=NULL;
|
|
} else {
|
|
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=cJSON_GetObjectItemCaseSensitive(root,"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;
|
|
}
|
|
|
|
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 ble_cloud_configured()?call_client_credential()->instance_id:s_volc.instance_id; }
|
|
const char *platform_runtime_product_key(void) { return ble_cloud_configured()?call_client_credential()->product_key:s_volc.product_key; }
|
|
const char *platform_runtime_device_name(void) { return ble_cloud_configured()?call_client_credential()->device_name:s_volc.device_name; }
|
|
const char *platform_runtime_device_secret(void) { return ble_cloud_configured()?call_client_credential()->device_secret:s_volc.device_secret; }
|
|
bool platform_runtime_rtc(void) { return ble_cloud_configured(); }
|
|
const char *platform_runtime_rtc_app_id(void) { return call_client_rtc_app_id(); }
|
|
void platform_runtime_engine_ready(bool ready) { call_client_engine_ready(ready); }
|
|
void platform_conversation_observe(const char *type) { call_client_observe(type); }
|
|
void platform_conversation_stop_result(bool started,int result) { call_client_stop_result(started,result); }
|
|
|
|
esp_err_t platform_conversation_begin(void)
|
|
{
|
|
if (ble_cloud_configured()) return call_client_begin();
|
|
atomic_store(&s_local_lease_active, false);
|
|
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;
|
|
TickType_t requested_at = xTaskGetTickCount();
|
|
esp_err_t result = platform_create_agent_session(&s_active_session);
|
|
if (result == ESP_OK) {
|
|
atomic_store(&s_local_lease_deadline, requested_at + pdMS_TO_TICKS(45000));
|
|
atomic_store(&s_local_lease_active, true);
|
|
s_heartbeat_due = requested_at + pdMS_TO_TICKS(5000);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const char *platform_conversation_bot_id(void) { return ble_cloud_configured()?call_client_bot_id():s_active_session.bot_id; }
|
|
const char *platform_conversation_update_json(void) { return ble_cloud_configured()?call_client_params():s_active_session.session_update_json; }
|
|
|
|
esp_err_t platform_conversation_started(const char *provider_connection_ref)
|
|
{
|
|
if (ble_cloud_configured()) { call_client_observe("MEDIA_CONNECTED"); return ESP_OK; }
|
|
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(5000);
|
|
return err;
|
|
}
|
|
|
|
int platform_conversation_poll(void)
|
|
{
|
|
if (ble_cloud_configured()) return call_client_poll();
|
|
if (!s_active_session.conversation_id[0] || !platform_conversation_audio_authorized()) 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(5000);
|
|
if (err == ESP_ERR_INVALID_STATE) { atomic_store(&s_local_lease_active, false); return 1; }
|
|
// Do not let an HTTP request that outlived the existing deadline resurrect audio.
|
|
if (!platform_conversation_audio_authorized()) { atomic_store(&s_local_lease_active, false); return 1; }
|
|
if (err == ESP_OK) atomic_store(&s_local_lease_deadline, now + pdMS_TO_TICKS(45000));
|
|
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 (ble_cloud_configured()) return call_client_turn(speaker,source_id,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 (ble_cloud_configured()) { call_client_finish(failed); return; }
|
|
atomic_store(&s_local_lease_active,false);
|
|
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);
|
|
}
|